diff --git a/.claude/skills/cleanup-feature-toggles/SKILL.md b/.claude/skills/cleanup-feature-toggles/SKILL.md new file mode 100644 index 0000000000..61a7873d5e --- /dev/null +++ b/.claude/skills/cleanup-feature-toggles/SKILL.md @@ -0,0 +1,320 @@ +--- +name: cleanup-feature-toggles +description: Remove released feature toggles (version <= target) — deletes from config, removes toggle properties, inlines `true` in calling code, removes dead branches. CI-safe, no prompts. +allowed-tools: Read, Grep, Glob, Bash, Edit, Write, Agent +argument-hint: [--dry-run] [--only ] +--- + +Remove all feature toggles whose version is less than or equal to the target release version. + +**CRITICAL: This skill runs on CI. NEVER ask questions. If anything is ambiguous, make the safer choice or skip the toggle.** + +## Constants + +- **Config file**: `core/config-toggles/src/main/assets/configs/feature_toggles_config.json` +- **Generated enum** (DO NOT edit): `core/config-toggles/build/generated/source/toggles/com/tangem/core/configtoggle/FeatureToggles.kt` +- **Dry-run mode**: check if `$ARGUMENTS` contains `--dry-run`. In dry-run mode, make NO file changes — only output what WOULD be removed (including affected files and usage sites). +- **Version**: extract the version number from `$ARGUMENTS` (e.g., `5.35`, `5.35.0`). The version is the first argument that matches a semver-like pattern (`X.Y` or `X.Y.Z`). +- **Only mode**: check if `$ARGUMENTS` contains `--only `. If present, process ONLY the specified toggle (it must still satisfy the version check). Multiple `--only` flags can be provided. + +## Phase 0: Preflight Checks + +### 0a. Parse Arguments + +Extract ``, optional `--dry-run`, and optional `--only ` (repeatable) from `$ARGUMENTS`. + +- If no version found: STOP with `FATAL: No version provided. Usage: /cleanup-feature-toggles [--dry-run] [--only ]` +- Validate version matches pattern `\d+\.\d+(\.\d+)?` — if not, STOP with `FATAL: Invalid version format.` +- Normalize version: if only `X.Y` is given, treat as `X.Y.0` for comparison. +- If `--only` flags are present, collect the toggle names into a filter list. + +### 0b. Verify Git State + +```bash +git status --porcelain 2>&1 +``` +- If output is empty (clean working tree) — OK. +- If there are uncommitted changes — STOP with: `FATAL: Working tree is not clean. Commit or stash changes before running this skill.` + +Initialize an internal results list to track each toggle's outcome. + +## Phase 1: Identify Toggles to Remove + +1. Read `core/config-toggles/src/main/assets/configs/feature_toggles_config.json`. +2. For each toggle entry in the JSON array: + - If `version == "undefined"` → skip (unreleased feature, must not be removed). + - Parse the toggle's version as semver (normalize `X.Y` to `X.Y.0`). + - If toggle version **<=** target version → mark for removal. +3. If `--only` filter is active: keep only toggles whose `name` matches one of the `--only` values. If a `--only` toggle doesn't satisfy the version check, output a warning but still skip it. +4. Output the list of toggles marked for removal with their versions. +5. If no toggles match → output `No toggles to remove for version ` and stop. +6. If `--dry-run` mode → proceed to Phase 2 (research only, all toggles in parallel), then skip to Phase 7 to output the detailed summary. Do NOT make any file changes. + +## Phase 2: Research (parallel) + +Collect all information about all toggles **in parallel** before making any edits. Launch one `Agent` per toggle (all in a single message so they run concurrently). Each agent receives the toggle name and must return a structured report. + +**Error handling rule**: if research fails for a toggle, record the failure reason and continue. Do NOT stop processing. + +### Per-toggle research task (runs inside each Agent) + +Each Agent performs the following read-only searches and returns a structured report: + +#### 2a. Find the toggle property declaration and direct usages + +Use `Grep` to search for `FeatureToggles.` (e.g., `FeatureToggles.WALLET_REORDER_FEATURE_ENABLED`) across the **entire** codebase. + +This will find: +1. **`DefaultXxxFeatureToggles` property** — the standard wrapper. Extract: + - The **property name** (e.g., `isWalletReorderFeatureEnabled`) + - The **DefaultXxxFeatureToggles file path** + - The **XxxFeatureToggles interface name** (from the class's supertype) +2. **Direct `FeatureTogglesManager.isFeatureEnabled()` calls** — code that bypasses the wrapper and calls the manager directly. These are additional usage sites. + +Also find the interface file: +- Use `Glob` to find the `XxxFeatureToggles.kt` file in `features/*/api/` or `core/*/` + +Check if the toggle property or the `DefaultXxxFeatureToggles` class has **comments referencing additional cleanup** (e.g., `// Remove GiveTxPermissionBottomSheet and all dependencies with this toggle`). If found, record the comment text. + +If the toggle reference is not found anywhere: report as `Skipped (no property found)`. + +#### 2b. Find `@RemoveWithToggle` annotated code + +Use `Grep` to search for `RemoveWithToggle` (without `@` or package prefix) across the entire codebase (excluding the annotation definition itself). Then filter matches to only those where the `toggleName` argument equals the current toggle name. + +The annotation is defined in `core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt` (`com.tangem.utils.annotations.RemoveWithToggle`). It has two parameters: `toggleName: String` (the toggle name) and `description: String` (optional hint). + +Support all Kotlin annotation forms: +- `@RemoveWithToggle("TOGGLE_NAME")` +- `@RemoveWithToggle(toggleName = "TOGGLE_NAME")` +- `@com.tangem.utils.annotations.RemoveWithToggle("TOGGLE_NAME")` +- `@com.tangem.utils.annotations.RemoveWithToggle(toggleName = "TOGGLE_NAME")` + +For each filtered match, record: +- The file path and line number +- The annotated element name (class, function, property) +- The `description` value if present + +#### 2c. Find all usages of the property in calling code + +Use `Grep` to search for the property name (e.g., `isWalletReorderFeatureEnabled`) across the entire codebase. + +Categorize results: +- **Interface declaration** — the `val isX: Boolean` in `XxxFeatureToggles.kt` +- **Implementation** — the `override val isX` in `DefaultXxxFeatureToggles.kt` +- **Calling code** — any other file that reads `*.isX` (include file path, line number, and the matched line content) + +#### Agent report format + +Each Agent must return a report with: +- Toggle name +- Property name (e.g., `isWalletReorderFeatureEnabled`) or `null` if not found +- Interface name and file path +- Implementation file path +- List of calling code sites: `[{file, line, content}]` +- List of direct `FeatureTogglesManager` usage sites: `[{file, line, content}]` +- List of `@RemoveWithToggle` sites: `[{file, line, element, description}]` +- Cleanup comments (if any) +- Status: `ready` or `skipped (reason)` + +### After all Agents complete + +Collect all reports. If `--dry-run` → skip to Phase 6 with the collected data. + +## Phase 3: Edit (sequential) + +Process each toggle **sequentially** using the research data from Phase 2. Only toggles with status `ready` are processed. + +**Error handling rule**: if ANY step fails for a toggle, record the failure reason and continue to the next toggle. Do NOT stop processing. + +### Step 3a: Replace usages in calling code with `true` and simplify + +For each calling code usage site (from Phase 2 report), `Read` the surrounding context (at least 20 lines around the usage) and apply the appropriate simplification: + +| Pattern | Simplification | +|---------|---------------| +| `if (toggles.isX) { body }` | Remove `if`, keep `body` (unindent) | +| `if (toggles.isX) { A } else { B }` | Keep only `A`, remove if/else structure | +| `if (!toggles.isX) { body }` | Remove entire if-block | +| `if (!toggles.isX) { A } else { B }` | Keep only `B`, remove if/else structure | +| `toggles.isX && expr` | Replace with `expr` | +| `expr && toggles.isX` | Replace with `expr` | +| `toggles.isX \|\| expr` | Replace with `true` (or simplify enclosing condition since it's always true) | +| `val x = toggles.isX` | Replace with `val x = true`, then check if `x` is used in one of the patterns above and simplify transitively | +| `property = toggles.isX` | Replace with `property = true` | +| `when { toggles.isX -> A; else -> B }` | Keep only `A`, remove the `when` structure | +| `when { !toggles.isX -> A; else -> B }` | Keep only `B`, remove the `when` structure | +| `when(value) { ... }` with toggle in a branch condition | Evaluate the toggle to `true`, simplify the `when` accordingly | +| Complex boolean expression | Replace `toggles.isX` with `true` and algebraically simplify | + +Also process any direct `FeatureTogglesManager.isFeatureEnabled()` call sites the same way (replace with `true` and simplify). + +**After replacing**, check if the file still references the `XxxFeatureToggles` type: +- If not → remove the import of `XxxFeatureToggles` +- If the type was a constructor/inject parameter and is no longer used → remove the parameter and any `@Inject`/`@Assisted` annotations associated with it +- If removing a constructor parameter from a Decompose Model or Component, also remove it from the caller that creates the instance + +**Important**: Use `Edit` for precise changes. Read enough context to make correct edits. Do NOT accidentally delete unrelated code. + +### Step 3b: Remove the property from interface and implementation + +1. **In `XxxFeatureToggles` interface**: remove the `val isPropertyName: Boolean` line. +2. **In `DefaultXxxFeatureToggles`**: remove the `override val isPropertyName: Boolean` property (including the `get() = ...` line). +3. Check if `DefaultXxxFeatureToggles` still has other properties: + - If **yes** → done with this toggle. + - If **no** (all properties removed) → the interface and implementation are now empty. Check the **protected list** below — if the interface is protected, keep it and skip deletion. Otherwise, delete them: + +**Protected interfaces (never delete even if empty):** +- `TokensFeatureToggles` +- `BlockchainSDKFeatureToggles` +- `StakingFeatureToggles` +- `CardSdkFeatureToggles` +- `TangemPayFeatureToggles` +- `SwapFeatureToggles` +- `SendFeatureToggles` + +If the interface is **protected**: remove the `featureTogglesManager` / `featureToggles` constructor parameter from `DefaultXxxFeatureToggles`, remove unused imports (`FeatureTogglesManager`, `FeatureToggles`), but keep both files. + +If the interface is **not protected** → delete them: + 1. Delete the `XxxFeatureToggles` interface file. + 2. Delete the `DefaultXxxFeatureToggles` implementation file. + 3. Find and remove the Hilt binding for this interface (typically a `@Binds` method in a `*FeatureTogglesModule` or similar Hilt module). If the Hilt module has no remaining bindings after removal, delete the module file as well. + 4. Use `Grep` to find all remaining references to `XxxFeatureToggles` and `DefaultXxxFeatureToggles` across the codebase. For each reference: + - **Constructor/inject parameter** → remove the parameter. If the surrounding class/function no longer uses any feature toggles, cascade the removal to its callers. + - **Import statement** → remove it. + - **Any other reference** → assess and remove or update as needed. + +Record status as `Removed` with the count of usage sites simplified. + +## Phase 4: Update JSON Config + +1. Read `feature_toggles_config.json`. +2. Remove all entries whose `name` matches a successfully removed toggle (status = `Removed`). +3. Write back the JSON with proper formatting: + - 2-space indentation + - Each entry on its own lines + - No trailing commas + - Match the existing file format exactly + +## Phase 5: Build, Test & Lint Verification + +Run all verification tasks in a **single Gradle invocation** to avoid repeated cold starts: + +### 5a. Build + Tests + Detekt + +```bash +./gradlew assembleGoogleDebug unitTest detekt detektMain :app:assembleGoogleMocked :app:assembleGoogleMockedAndroidTest +``` + +- If the command **fails**: + - Read the error output to determine which task failed. + - **Compilation error** (`assembleGoogleDebug` or `assembleGoogleMocked`): attempt to fix (one retry — usually missing import removal or unused parameter). If still fails: revert all changes with `git checkout -- .` and output `FATAL: Build failed after cleanup. All changes reverted.` with the error details. + - **Unit test failure**: attempt to fix (one retry). If still fails: output the failures as warnings in the summary but do NOT revert. + - **Detekt violation**: attempt to fix (one retry — usually unused imports or parameters). If still fails: output the violations as warnings in the summary. + - **UI test compilation failure**: attempt to fix (one retry). If still fails: output the failures as warnings in the summary. + - After fixing, re-run the **full command** to verify everything passes together. + +## Phase 6: Branch, Commit, Push & PR + +Skip this phase entirely in `--dry-run` mode. + +### 6a. Create Branch and Commit + +```bash +git checkout -b tech/cleanup-toggles- +git add -A +git commit -m "[Tech] Remove feature toggles <= " +``` + +Replace `` with the target version (e.g., `tech/cleanup-toggles-5.35`). + +### 6b. Push + +```bash +git push -u origin tech/cleanup-toggles- +``` + +### 6c. Create Pull Request + +Use `gh pr create` targeting `develop`: + +```bash +gh pr create --base develop --title "Remove feature toggles <= " --body "$(cat <<'EOF' +## Summary + +Automated cleanup of feature toggles that are permanently enabled (version <= ). + +### Removed toggles + +- `TOGGLE_NAME_1` (version) +- `TOGGLE_NAME_2` (version) +- ... + +### Manual review required + + + +## Test plan + +- [x] `assembleGoogleDebug` passes +- [x] `unitTest` passes +- [x] `detekt detektMain` passes + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Output the PR URL. + +## Phase 7: Output Summary + +Output results as a Markdown table: + +```markdown +## Feature Toggle Cleanup Summary + +| Toggle | Version | Interface | Usages Simplified | Status | +|--------|---------|-----------|-------------------|--------| +| WALLET_REORDER_FEATURE_ENABLED | 5.34 | WalletFeatureToggles | 3 | Removed | +| EARN_BLOCK_ENABLED | 5.35 | EarnFeatureToggles | 1 | Removed | +| SOME_TOGGLE | 5.33 | SomeFeatureToggles | — | Skipped (no property found) | + +**Total:** X toggles processed, Y removed, Z skipped/failed +**Target version:** +``` + +### Manual Review Hints + +If any toggle had a comment referencing additional cleanup (found in Phase 2 research), output a separate section: + +```markdown +### Manual Review Required + +- **GASLESS_APPROVAL_ENABLED**: `// Remove GiveTxPermissionBottomSheet and all dependencies with this toggle` +- **OTHER_TOGGLE**: `// Also remove legacy FooBar component` +``` + +### Dry-run mode output + +In `--dry-run` mode: prepend `[DRY RUN]` to the header, set all statuses to `Would remove`, and add a detailed section per toggle: + +```markdown +### WALLET_REORDER_FEATURE_ENABLED (5.34) — Would remove + +**Property:** `WalletFeatureToggles.isWalletReorderFeatureEnabled` +**Files affected:** +- `features/details/impl/.../UserWalletListModel.kt:42` — `walletFeatureToggles.isWalletReorderFeatureEnabled && userWallets.size > 1` +- `features/wallet/impl/.../SomeOtherFile.kt:88` — `if (walletFeatureToggles.isWalletReorderFeatureEnabled)` +``` + +### Warnings + +If unit tests or detekt failed after fix attempts, list the remaining issues: + +```markdown +### Warnings + +- **Unit test failure:** `:features:wallet:impl:testDebugUnitTest` — WalletModelTest.someTest (may need manual update) +- **Detekt violation:** UnusedPrivateMember in `SomeFile.kt:15` +``` \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2bb419fc52..e090880fa9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,7 +61,7 @@ android { } flavorDimensions += "services" - + productFlavors { create("google") { dimension = "services" @@ -73,6 +73,15 @@ android { } } + // `src/prodDi/` holds production DI bindings for interfaces with a `mocked` counterpart. + // Wired into every build type EXCEPT `mocked`, which supplies its own bindings from `src/mocked/`. + buildTypes.configureEach { + if (name != "mocked") { + sourceSets.named(name) { + java.srcDir("src/prodDi/java") + } + } + } } configurations.all { @@ -161,7 +170,7 @@ dependencies { implementation(projects.domain.hotWallet) implementation(projects.domain.news) implementation(projects.domain.earn) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) implementation(projects.domain.search) implementation(projects.common) @@ -192,7 +201,7 @@ dependencies { implementation(projects.data.common) implementation(projects.data.settings) implementation(projects.data.tokens) - implementation(projects.data.tokensync) + implementation(projects.data.assetsdiscovery) implementation(projects.data.txhistory) implementation(projects.data.wallets) implementation(projects.data.analytics) @@ -276,6 +285,8 @@ dependencies { implementation(projects.features.nft.impl) implementation(projects.features.walletconnect.api) implementation(projects.features.walletconnect.impl) + implementation(projects.features.commonFeatures.api) + implementation(projects.features.commonFeatures.impl) implementation(projects.features.usedesk.api) implementation(projects.features.usedesk.impl) implementation(projects.features.hotWallet.api) @@ -375,7 +386,6 @@ dependencies { implementation(deps.googlePlay.services) implementation(deps.googlePlay.advertising) coreLibraryDesugaring(deps.desugar) - implementation(deps.kermit) implementation(deps.zxing.qrCore) implementation(deps.coil) implementation(deps.coil.gif) @@ -394,10 +404,8 @@ dependencies { implementation(deps.viewBindingDelegate) implementation(deps.armadillo) implementation(deps.kotlin.serialization) - implementation(deps.reKotlin) implementation(deps.reownCore) implementation(deps.reownWeb3) - implementation(deps.prettyLogger) implementation(deps.decompose.ext.compose) implementation(deps.moshi.adapters) implementation(deps.moshi.kotlin) diff --git a/app/libs/rekotlin-1.0.4.jar b/app/libs/rekotlin-1.0.4.jar deleted file mode 100644 index 3221e554be..0000000000 Binary files a/app/libs/rekotlin-1.0.4.jar and /dev/null differ diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 3b89b53e54..6acf072e3c 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -8,6 +8,8 @@ import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import com.kaspersky.components.alluresupport.interceptors.step.ScreenshotStepInterceptor import com.kaspersky.components.alluresupport.withForcedAllureSupport import com.kaspersky.components.composesupport.config.addComposeSupport @@ -20,13 +22,14 @@ import com.tangem.common.rules.ApiEnvironmentRule import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.models.PromoId +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.tap.MainActivity import dagger.hilt.android.testing.HiltAndroidRule import io.qameta.allure.kotlin.Allure -import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.rules.RuleChain import org.junit.rules.TestRule @@ -58,6 +61,12 @@ abstract class BaseTestCase : TestCase( @Inject lateinit var promoRepository: PromoRepository + @Inject + lateinit var walletManagersStore: WalletManagersStore + + @Inject + lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase + private val hiltRule = HiltAndroidRule(this) private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( @@ -72,7 +81,11 @@ abstract class BaseTestCase : TestCase( private val semanticTreePrinterRule = object : TestWatcher() { override fun failed(e: Throwable?, description: Description?) { - runCatching { printAllRoots() } + runCatching { + runBlocking { + withTimeoutOrNull(SEMANTIC_TREE_PRINT_TIMEOUT_MS) { printAllRoots() } + } + } } } @@ -174,5 +187,6 @@ abstract class BaseTestCase : TestCase( private companion object { const val WIREMOCK_BASE_URL_ARG = "wiremockBaseUrl" + const val SEMANTIC_TREE_PRINT_TIMEOUT_MS = 5_000L } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index fd5d74d374..0974d1c8fb 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -32,9 +32,12 @@ object TestConstants { const val DOGECOIN_RECIPIENT_ADDRESS = "DJQR3bdhBKcFGMHX2BkMCkrMFApNWNzr6V" const val DOGECOIN_ADDRESS = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaqz" const val TERRA_RECIPIENT_ADDRESS = "terra148dmp5ccazcwdmrcpvqz5rprnn886kemqen3tj" + const val POLYGON_RECIPIENT_ADDRESS = "0x742d35cc6634c0532925a3b844bc9e7595f2bd18" const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L + const val WAIT_UNTIL_TIMEOUT_VERY_LONG = 60_000L + const val HOLD_DURATION_MS = 2_000L const val MARKETS_MAIN_NETWORK_SUFFIX = "MAIN" @@ -42,5 +45,17 @@ object TestConstants { const val ALLURE_LABEL_VALUE = "Kaspresso" const val USER_TOKENS_API_SCENARIO = "user_tokens_api" + const val REFERRAL_API_SCENARIO = "referral_api" const val QUOTES_API_SCENARIO = "quotes_api" + + const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" + const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " + + "hawk when" + const val SEED_PHRASE_18 = "crush idle include refuse expose kiss slot budget uphold when dinner certain holiday " + + "slow word armor butter suffer" + const val SEED_PHRASE_21 = "employ space oval venue wash clog zebra cover icon wash assist word debris inform " + + "cable meadow add game meat rigid pride" + const val SEED_PHRASE_24 = "force visit fresh brown razor target ill scissors figure cave feel genre cargo category " + + "bread much nature basic fun iron benefit egg error prosper" + const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/AddressComparisonHelper.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/AddressComparisonHelper.kt new file mode 100644 index 0000000000..2faf0b96ab --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/AddressComparisonHelper.kt @@ -0,0 +1,88 @@ +package com.tangem.common.utils + +import com.tangem.utils.logging.TangemLogger +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertTrue + +/** + * Compares addresses from the app (clipboard JSON) with reference addresses from the QA tools API. + * + * Both JSON arrays contain objects with fields: blockchain, derivationPath, token (nullable), addresses (array). + * Comparison normalizes and sorts both arrays before diffing. + */ +object AddressComparisonHelper { + + fun compareAddresses(appJson: String, apiJson: String) { + val appEntries = parseAndNormalize(appJson) + val apiEntries = parseAndNormalize(apiJson) + + val missingInApp = apiEntries - appEntries.toSet() + val extraInApp = appEntries - apiEntries.toSet() + + if (missingInApp.isEmpty() && extraInApp.isEmpty()) { + TangemLogger.i("Address comparison passed: ${appEntries.size} entries match") + return + } + + val report = buildString { + appendLine("Address comparison FAILED") + if (missingInApp.isNotEmpty()) { + appendLine("\nMissing in app (expected from API but not found):") + missingInApp.forEach { appendLine(" - $it") } + } + if (extraInApp.isNotEmpty()) { + appendLine("\nExtra in app (found in app but not in API):") + extraInApp.forEach { appendLine(" - $it") } + } + appendLine("\nApp entries: ${appEntries.size}, API entries: ${apiEntries.size}") + } + + TangemLogger.e(report) + assertTrue(report, false) + } + + private fun parseAndNormalize(json: String): List { + val array = JSONArray(json) + val entries = mutableListOf() + + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + entries.add( + AddressEntry( + blockchain = normalizeBlockchainName(obj.getString("blockchain")), + derivationPath = obj.getString("derivationPath").trim(), + token = obj.optString("token", null)?.trim()?.lowercase(), + addresses = parseAddresses(obj).sorted(), + ), + ) + } + + return entries.sortedWith( + compareBy { it.blockchain } + .thenBy { it.derivationPath } + .thenBy { it.token }, + ) + } + + private val blockchainNameOverrides = mapOf( + "chia network" to "chia", + ) + + private fun normalizeBlockchainName(name: String): String { + val normalized = name.trim().lowercase() + return blockchainNameOverrides[normalized] ?: normalized + } + + private fun parseAddresses(obj: JSONObject): List { + val addressesArray = obj.getJSONArray("addresses") + return (0 until addressesArray.length()).map { addressesArray.getString(it) } + } + + private data class AddressEntry( + val blockchain: String, + val derivationPath: String, + val token: String?, + val addresses: List, + ) +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt index 48c2a5cc5a..27b8c945ad 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt @@ -16,10 +16,10 @@ fun getWcUri( TangemLogger.i("Getting WC URI for network: $network") val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) // Таймаут подключения - .readTimeout(60, TimeUnit.SECONDS) // Таймаут чтения ответа - .writeTimeout(30, TimeUnit.SECONDS) // Таймаут записи - .callTimeout(90, TimeUnit.SECONDS) // Общий таймаут запроса + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .callTimeout(90, TimeUnit.SECONDS) .build() val request = Request.Builder() @@ -58,6 +58,59 @@ fun getWcUri( } } +fun getAddressesFromApi( + seedKey: String, + baseUrl: String = "[REDACTED_ENV_URL]", +): String? { + TangemLogger.i("Getting addresses for seed key: $seedKey") + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .callTimeout(90, TimeUnit.SECONDS) + .build() + + val request = Request.Builder() + .url("$baseUrl/addresses") + .get() + .build() + + return try { + client.newCall(request).execute().use { response -> + TangemLogger.i("Response code: ${response.code}") + + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + + val contentType = response.header("Content-Type") ?: "" + if (!contentType.contains("application/json") && !body.trimStart().startsWith("{")) { + TangemLogger.e("Unexpected response (not JSON), Content-Type: $contentType, body: $body") + return null + } + + val jsonObject = JSONObject(body) + val data = jsonObject.optJSONObject("data") ?: jsonObject + val seedData = data.optJSONArray(seedKey) + + if (seedData != null) { + TangemLogger.i("Got addresses for $seedKey: ${seedData.length()} entries") + seedData.toString() + } else { + TangemLogger.e("No data found for seed key: $seedKey") + null + } + } else { + val errorBody = response.body?.string() ?: "No error body" + TangemLogger.e("Request failed: ${response.code}, body: $errorBody") + null + } + } + } catch (e: Exception) { + TangemLogger.e("Error getting addresses", e) + null + } +} + fun checkServiceHealth( baseUrl: String = "[REDACTED_ENV_URL]" ): String? { diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt new file mode 100644 index 0000000000..7320642036 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt @@ -0,0 +1,102 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.accounts.onAccountDetailsScreen +import com.tangem.screens.accounts.onArchivedAccountsScreen +import com.tangem.screens.onDetailsScreen +import com.tangem.screens.onDialog +import com.tangem.screens.onMainScreenTopBar +import com.tangem.screens.onWalletSettingsScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openWalletSettingsScreen() { + step("Open 'Wallet details' screen") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings' screen") { + onDetailsScreen { walletNameButton.clickWithAssertion() } + } +} + +fun BaseTestCase.openAccountDetails(accountName: String) { + step("Click on account: '$accountName'") { + onWalletSettingsScreen { accountItem(accountName).clickWithAssertion() } + } + step("Assert 'Account details' screen is displayed") { + onAccountDetailsScreen { screenContainer.assertIsDisplayed() } + } +} + +fun BaseTestCase.archiveAccount() { + step("Assert 'Archive' button is displayed") { + onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } + } + step("Click on 'Archive' button") { + onAccountDetailsScreen { archiveAccountButton.clickWithAssertion() } + } + step("Confirm archivation in dialog") { + onDialog { archiveButton.clickWithAssertion() } + } +} + +fun BaseTestCase.assertArchiveConfirmationDialog() { + step("Assert confirmation dialog is displayed") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert confirmation dialog has 'Cancel' button") { + onDialog { cancelButton.assertIsDisplayed() } + } + step("Assert confirmation dialog has 'Archive' button") { + onDialog { archiveButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.assertErrorDialog(expectedTitle: String, expectedMessage: String) { + step("Assert error dialog is displayed") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert error dialog has proper title") { + onDialog { + title.assertTextContains(expectedTitle) + } + } + step("Assert error dialog has explanatory text") { + onDialog { + text.assertTextContains(expectedMessage) + } + } + step("Assert error dialog has 'OK' button") { + onDialog { okButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.dismissErrorDialog() { + step("Dismiss error dialog by clicking 'Ok' button") { + onDialog { okButton.clickWithAssertion() } + } +} + +fun BaseTestCase.openArchivedAccountsScreen() { + step("Click on 'Archived accounts' button") { + onWalletSettingsScreen { openArchivedAccountsButton.clickWithAssertion() } + } +} + +fun BaseTestCase.assertArchivedAccountIsDisplayed(accountName: String) { + step("Assert archived account with name '$accountName' is displayed") { + onArchivedAccountsScreen { + findArchivedAccountItemByName(accountName) + .container.assertIsDisplayed() + } + } +} + +fun BaseTestCase.restoreArchivedAccount(accountName: String) { + step("Restore account with name '$accountName'") { + onArchivedAccountsScreen { + findArchivedAccountItemByName(accountName) + .restoreButton.clickWithAssertion() + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt new file mode 100644 index 0000000000..f259dd60c4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt @@ -0,0 +1,122 @@ +package com.tangem.scenarios + +import android.view.KeyEvent +import androidx.test.core.app.ApplicationProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG +import com.tangem.common.utils.AddressComparisonHelper +import com.tangem.common.utils.getClipboardText +import com.tangem.screens.onMainScreen +import com.tangem.screens.onTesterMenuScreen +import com.tangem.utils.StringsSigns.DASH_SIGN +import com.tangem.utils.logging.TangemLogger +import io.qameta.allure.kotlin.Allure.step +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertNotNull + +private const val WALLET_MANAGERS_SETTLE_MS = 5_000L +private const val WALLET_MANAGERS_POLL_INTERVAL_MS = 500L + +fun BaseTestCase.verifyAddresses(seedPhrase: String, apiAddressesJson: String) { + var appAddressesJson: String? = null + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Assert wallet balance = '$DASH_SIGN'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } }.isSuccess + } + } + step("Assert 'Organize tokens' button is enabled") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onMainScreen { organizeTokensButton().assertIsEnabled() } }.isSuccess + } + } + step("Wait for all wallet managers to initialize") { + awaitWalletManagersStabilized() + } + step("Open tester menu") { + openTesterMenu() + } + step("Click on 'Addresses info' button") { + onTesterMenuScreen { addressesInfoButton.performClick() } + } + step("Click on 'JSON' tab") { + onTesterMenuScreen { jsonTab.performClick() } + } + step("Click on 'Copy' button") { + onTesterMenuScreen { copyButton.performClick() } + } + step("Get addresses JSON from clipboard") { + appAddressesJson = getClipboardText(ApplicationProvider.getApplicationContext()) + assertNotNull("Clipboard is empty after copying addresses", appAddressesJson) + } + step("Compare app addresses with API reference") { + AddressComparisonHelper.compareAddresses( + appJson = requireNotNull(appAddressesJson), + apiJson = apiAddressesJson, + ) + } +} + +private const val TESTER_MENU_MAX_ATTEMPTS = 3 + +/** + * Presses 'Volume Down' twice to open tester menu. + * Retries up to [TESTER_MENU_MAX_ATTEMPTS] times if the menu doesn't appear. + */ +private fun BaseTestCase.openTesterMenu() { + repeat(TESTER_MENU_MAX_ATTEMPTS) { attempt -> + waitForIdle() + device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN) + device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN) + + val opened = runCatching { + onTesterMenuScreen { addressesInfoButton.assertIsDisplayed() } + }.isSuccess + + if (opened) { + TangemLogger.i("Tester menu opened on attempt ${attempt + 1}") + return + } + TangemLogger.w("Tester menu not opened on attempt ${attempt + 1}, retrying...") + } + error("Failed to open tester menu after $TESTER_MENU_MAX_ATTEMPTS attempts") +} + +/** + * Polls [walletManagersStore] until the wallet manager count stops growing for [WALLET_MANAGERS_SETTLE_MS]. + + * + * Uses [getAllSync] with a polling interval instead of Flow, because the Flow only emits on changes — + * if the count stabilizes, there would be no new emission to check the settle timeout against. + */ +private fun BaseTestCase.awaitWalletManagersStabilized() { + val walletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId + ?: error("No selected wallet found") + var lastSize = -1 + var stableStart = System.currentTimeMillis() + + runBlocking { + withTimeout(WAIT_UNTIL_TIMEOUT_VERY_LONG) { + while (true) { + val currentSize = walletManagersStore.getAllSync(walletId).size + val now = System.currentTimeMillis() + + if (currentSize != lastSize) { + TangemLogger.i("Wallet managers count: $currentSize (was $lastSize)") + lastSize = currentSize + stableStart = now + } else if (now - stableStart >= WALLET_MANAGERS_SETTLE_MS) { + TangemLogger.i("Wallet managers stabilized at $currentSize entries") + return@withTimeout + } + + delay(WALLET_MANAGERS_POLL_INTERVAL_MS) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 85e746d709..22a818f96c 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -15,14 +15,10 @@ fun BaseTestCase.scanCard( mockContent: MockContent? = null, isTwinsCard: Boolean = false, ) { - if (productType != null) { - MockProvider.setMocks(productType) - } - if (mockContent != null) { - MockProvider.setMocks(mockContent) - } - step("Click on 'Accept' button") { - onDisclaimerScreen { acceptButton.clickWithAssertion() } + when { + mockContent != null -> MockProvider.setMocks(mockContent) + productType != null -> MockProvider.setMocks(productType) + else -> MockProvider.setMocks(ProductType.Wallet) } step("Click on 'Get started' button") { onStoriesScreen { getStartedButton.clickWithAssertion() } @@ -60,6 +56,54 @@ fun BaseTestCase.openMainScreen( } } +fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String) { + step("Click on 'Get started' button") { + onStoriesScreen { getStartedButton.clickWithAssertion() } + } + step("Click on 'Start with Mobile Wallet' button") { + onCreateWalletStartScreen { startWithMobileWalletButton.performClick() } + } + step("Click on 'Import existing wallet' button") { + onCreateMobileWalletScreen { importExistingWalletButton.performClick() } + } + step("Click on 'Phrase text field'") { + onImportWalletScreen { phraseTextField.performClick() } + } + step("Type seed phrase in 'Phrase text field'") { + onImportWalletScreen { phraseTextField.performTextReplacement(seedPhrase) } + } + step("Click on 'Import' button") { + onImportWalletScreen { + importButton.assertIsEnabled() + importButton.performClick() + } + } + step("Click on 'Continue' button") { + onImportWalletScreen { + continueButton.assertIsEnabled() + continueButton.performClick() + } + } + step("Click on 'Skip' button") { + onImportWalletScreen { skipButton.performClick() } + } + step("Click on 'Skip anyway' dialog button") { + onDialog { skipAnywayButton.performClick() } + } + step("Click on 'Finish' button") { + onImportWalletScreen { + finishButton.assertIsEnabled() + finishButton.performClick() + } + } + step("Assert 'Main' screen is displayed") { + onMainScreen { screenContainer.assertIsDisplayed() } + } + step("Dismiss Market Tooltip by clicking close button") { + onMarketsTooltipScreen { closeButton.clickWithAssertion() } + } +} + fun BaseTestCase.synchronizeAddresses( balance: String? = null, isBalanceAvailable: Boolean = true @@ -84,6 +128,10 @@ fun BaseTestCase.synchronizeAddresses( onMainScreen { totalBalanceText.assert(!hasText(DASH_SIGN)) } } } + + step("Expand 'Main account' to reveal tokens") { + onMainScreen { mainAccount().performClick() } + } } fun BaseTestCase.openDeviceSettingsScreen() { diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index f7bcef3905..5277c76ba8 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -1,18 +1,23 @@ package com.tangem.scenarios +import androidx.compose.ui.test.longClick import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.assertIsDimmed import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.setWireMockScenarioState import com.tangem.screens.* -import com.tangem.screens.onMainScreen -import com.tangem.screens.onSendConfirmScreen -import com.tangem.screens.onSendScreen -import com.tangem.screens.onTokenDetailsScreen +import com.tangem.tap.domain.sdk.mocks.MockContent import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.openSendScreen(tokenName: String, mockState: String = "") { +fun BaseTestCase.openSendScreen( + tokenName: String, + mockState: String = "", + mockContent: MockContent? = null, +) { val scenarioState = mockState.ifEmpty { tokenName } step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState) @@ -21,7 +26,7 @@ fun BaseTestCase.openSendScreen(tokenName: String, mockState: String = "") { setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState) } step("Open 'Main Screen'") { - openMainScreen() + openMainScreen(mockContent = mockContent) } step("Synchronize addresses") { synchronizeAddresses() @@ -72,8 +77,10 @@ fun BaseTestCase.openSendConfirmScreen( step("Type recipient address") { onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Click 'Next' button until 'Send Confirm' screen opens") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { openSendConfirmScreenViaNextButton() }.isSuccess + } } } @@ -84,6 +91,9 @@ fun BaseTestCase.openSendAddressScreen( step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } + step("Assert 'Send' button is not dimmed") { + onTokenDetailsScreen { sendButton().assertIsDimmed(false) } + } step("Click on 'Send' button") { onTokenDetailsScreen { sendButton().performClick() } } @@ -181,4 +191,70 @@ fun BaseTestCase.openSendConfirmScreenViaNextButton() { step("Assert 'Send' button on 'Send confirm' screen is displayed") { onSendConfirmScreen { sendButton.assertIsDisplayed() } } +} + +fun BaseTestCase.openSendSuccessScreenViaLongClickOnSendButton() { + step("Long click on 'Send' button") { + onSendConfirmScreen { + waitForIdle() + sendButton.assertIsEnabled() + sendButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } + } + } + step("Assert 'Transaction sent' screen is displayed") { + onSendSuccessScreen { container.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkSendViaSwapSuccessScreen() { + step("Assert 'Transaction sent' title is displayed") { + onSendSuccessScreen { title.assertIsDisplayed() } + } + step("Assert 'Transaction date' is displayed") { + onSendSuccessScreen { transactionDate.assertIsDisplayed() } + } + step("Assert 'Send from' block is displayed") { + onSendSuccessScreen { sendFromBlock.assertIsDisplayed() } + } + step("Assert 'Amount to receive' block is displayed") { + onSendSuccessScreen { sendToAmountBlock.assertIsDisplayed() } + } + step("Assert 'Provider' block is displayed") { + onSendSuccessScreen { providerBlock.assertIsDisplayed() } + } + step("Assert 'Recipient address' block is displayed") { + onSendSuccessScreen { recipientAddressBlock.assertIsDisplayed() } + } + step("Assert 'Network fee' block is displayed") { + onSendSuccessScreen { feeBlock.assertIsDisplayed() } + } + step("Assert 'Explore' button is displayed") { + onSendSuccessScreen { exploreButton.assertIsDisplayed() } + } + step("Assert 'Share' button is displayed") { + onSendSuccessScreen { shareButton.assertIsDisplayed() } + } + step("Assert 'Close' button is displayed") { + onSendSuccessScreen { closeButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.selectTokenToSendViaSwap( + swapTokenName: String, + networkName: String, + networkType: String? = null, +) { + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton().performClick() } + } + step("Click on 'Swap to another token' button") { + onSendScreen { swapToAnotherTokenButton.performClick() } + } + step("Click on token: '$swapTokenName'") { + onSendViaSwapScreen { tokenItem(swapTokenName).performClick() } + } + val networkLabel = if (networkType.isNullOrBlank()) networkName else "$networkName $networkType" + step("Click on '$networkLabel' network") { + onChooseNetworkBottomSheet { networkItem(networkName, networkType).performClick() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 358b29b5b3..43fdc8264d 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -297,6 +297,15 @@ fun BaseTestCase.checkSwapWarning( } } +fun BaseTestCase.chooseReceiveToken(tokenName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Click on token with name '$tokenName'") { + onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() } + } +} + sealed class SwapEntryPoint { object MainScreen : SwapEntryPoint() object TokenDetails : SwapEntryPoint() diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt index 1c179d6575..4a200f8033 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt @@ -20,10 +20,12 @@ class ChooseNetworkBottomSheetPageObject(semanticsProvider: SemanticsNodeInterac useUnmergedTree = true } - fun networkItem(title: String, subtitle: String): KNode = child { + fun networkItem(name: String, type: String? = null): KNode = child { hasTestTag(ChooseNetworkBottomSheetTestTags.NETWORK_ITEM) - hasAnyDescendant(withText(title)) - hasAnyDescendant(withText(subtitle)) + hasAnyDescendant(withText(name)) + if (!type.isNullOrBlank()) { + hasAnyDescendant(withText(type)) + } useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/CreateMobileWalletPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/CreateMobileWalletPageObject.kt new file mode 100644 index 0000000000..e83def9363 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/CreateMobileWalletPageObject.kt @@ -0,0 +1,23 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.core.ui.test.BaseButtonTestTags +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 + +class CreateMobileWalletPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val importExistingWalletButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.hw_import_existing_wallet)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onCreateMobileWalletScreen(function: CreateMobileWalletPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt index b06335d752..6d875cd034 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt @@ -17,6 +17,11 @@ class CreateWalletStartPageObject(semanticsProvider: SemanticsNodeInteractionsPr hasText(getResourceString(OnboardingImplR.string.welcome_unlock_card)) useUnmergedTree = true } + + val startWithMobileWalletButton: KNode = child { + hasText(getResourceString(OnboardingImplR.string.welcome_create_wallet_mobile_title)) + useUnmergedTree = true + } } internal fun BaseTestCase.onCreateWalletStartScreen(function: CreateWalletStartPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index 13eef6b87d..87e72b5b05 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -40,6 +40,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasText(getResourceString(R.string.common_confirm)) } + val archiveButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.account_details_archive_action)) + } + val continueButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_continue)) @@ -64,6 +69,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_change)) } + + val skipAnywayButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.access_code_alert_skip_ok)) + } } internal fun BaseTestCase.onDialog(function: DialogPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ImportWalletPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ImportWalletPageObject.kt new file mode 100644 index 0000000000..26d1af7a54 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ImportWalletPageObject.kt @@ -0,0 +1,54 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.ImportWalletScreenTestTags +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.hasText as withText + +class ImportWalletPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val phraseTextField: KNode = child { + hasTestTag(ImportWalletScreenTestTags.PHRASE_TEXT_FIELD) + useUnmergedTree = true + } + + val passphraseTextField: KNode = child { + hasTestTag(ImportWalletScreenTestTags.PASSPHRASE_TEXT_FIELD) + useUnmergedTree = true + } + + val importButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyChild(withText(getResourceString(R.string.common_import))) + useUnmergedTree = true + } + + val continueButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyChild(withText(getResourceString(R.string.common_continue))) + useUnmergedTree = true + } + + val skipButton: KNode = child { + hasTestTag(TopAppBarTestTags.MORE_BUTTON) + hasText(getResourceString(R.string.common_skip)) + useUnmergedTree = true + } + + val finishButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyChild(withText(getResourceString(R.string.common_finish))) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onImportWalletScreen(function: ImportWalletPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 79bc25c0f5..abdbaeb2a7 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -219,6 +219,24 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + /** + * Main account header on the main screen. Click to expand/collapse its tokens list. + */ + fun mainAccount(): LazyListItemNode = accountWithName(getResourceString(CoreUiR.string.account_main_account_title)) + + /** + * Account header on the main screen, located by its visible name. Click to expand/collapse its tokens list. + * The account's title text lives on a descendant of the test-tagged node, so we match by descendant. + */ + @OptIn(ExperimentalTestApi::class) + fun accountWithName(name: String): LazyListItemNode { + return lazyList.childWith { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasAnyDescendant(withText(name)) + useUnmergedTree = true + } + } + /** * Find token list item with title and address */ @@ -226,7 +244,8 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithTitleAndAddress(tokenTitle: String): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasText(tokenTitle) + hasAnyDescendant(withText(tokenTitle)) + useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT) useUnmergedTree = true @@ -237,7 +256,8 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasText(tokenTitle) + hasAnyDescendant(withText(tokenTitle)) + useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_CUSTOM_DERIVATION_ICON) useUnmergedTree = true @@ -277,8 +297,9 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasText(tokenTitle) + hasAnyDescendant(withText(tokenTitle)) hasLazyListItemPosition(index) + useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_TITLE) useUnmergedTree = true diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendSuccessPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendSuccessPageObject.kt new file mode 100644 index 0000000000..c593a2566d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendSuccessPageObject.kt @@ -0,0 +1,77 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags +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.hasText as withText + +class SendSuccessPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val container: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.CONTAINER) + } + + val title: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.TITLE) + useUnmergedTree = true + } + + val transactionDate: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.TRANSACTION_DATE) + useUnmergedTree = true + } + + val sendFromBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.AMOUNT_BLOCK) + hasAnyDescendant(withText(getResourceString(R.string.send_from_title))) + useUnmergedTree = true + } + + val sendToAmountBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.AMOUNT_BLOCK) + hasAnyDescendant( + withText(getResourceString(R.string.send_with_swap_recipient_amount_success_title)) + ) + useUnmergedTree = true + } + + val providerBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.PROVIDER_BLOCK) + useUnmergedTree = true + } + + val recipientAddressBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.RECIPIENT_BLOCK) + useUnmergedTree = true + } + + val feeBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.FEE_BLOCK) + useUnmergedTree = true + } + + val exploreButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_explore)) + } + + val closeButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_close)) + } + + val shareButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_share)) + } +} + +internal fun BaseTestCase.onSendSuccessScreen(function: SendSuccessPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt deleted file mode 100644 index 77b3e18ad1..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import com.tangem.common.BaseTestCase -import com.tangem.core.ui.R -import com.tangem.core.ui.test.AppBarWithSearchTestTags -import com.tangem.core.ui.test.BuyTokenScreenTestTags -import com.tangem.core.ui.test.MarketsTestTags -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.hasText as withText - -class SwapChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { - - val title: KNode = child { - hasText(getResourceString(R.string.common_choose_token)) - } - - val myTokensTitle: KNode = child { - hasText(getResourceString(R.string.exchange_tokens_available_tokens_header)) - } - - val searchIcon: KNode = child { - hasTestTag(AppBarWithSearchTestTags.SEARCH_ICON) - } - - val searchTextField: KNode = child { - hasTestTag(AppBarWithSearchTestTags.TEXT_FIELD) - } - - val noTokensFoundText: KNode = child { - hasText(getResourceString(R.string.express_token_list_empty_search)) - } - - fun tokenWithTitle(tokenTitle: String, availableForSwap: Boolean = true): KNode = child { - hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) - hasAnyDescendant(withText(tokenTitle)) - if (!availableForSwap) { - hasAnyDescendant( - withText( - getResourceString(R.string.tokens_list_unavailable_to_swap_source_header) - ) - ) - } - useUnmergedTree = true - } - - fun marketsTokenWithTitle(title: String): KNode { - return child { - hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) - hasText(title) - } - } -} - -internal fun BaseTestCase.onSwapChooseTokenScreen(function: SwapChooseTokenPageObject.() -> Unit) = - onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt index b07573e5da..56075f5436 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt @@ -29,8 +29,8 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv } val youSwapBlock: KNode = child { - hasTestTag(SwapSelectTokenScreenTestTags.YOU_SWAP_BLOCK) - hasAnyDescendant(withText(getResourceString(R.string.action_buttons_you_want_to_swap))) + hasTestTag(SwapTokenScreenTestTags.SWAP_CARD) + hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title))) useUnmergedTree = true } @@ -40,8 +40,8 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv } val youReceiveBlock: KNode = child { - hasTestTag(SwapSelectTokenScreenTestTags.YOU_SWAP_BLOCK) - hasAnyDescendant(withText(getResourceString(R.string.action_buttons_you_want_to_receive))) + hasTestTag(SwapTokenScreenTestTags.RECEIVE_CARD) + hasAnyDescendant(withText(getResourceString(R.string.swapping_to_title))) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index e313cac282..73c09bf564 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -174,8 +174,16 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasTestTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT) } - val selectTokenIcon: KNode = child { + val swapSelectTokenIcon: KNode = child { + hasAnyAncestor(withTestTag(SwapTokenScreenTestTags.SWAP_CARD)) hasTestTag(SwapTokenScreenTestTags.SELECT_TOKEN_ICON) + useUnmergedTree = true + } + + val receiveSelectTokenIcon: KNode = child { + hasAnyAncestor(withTestTag(SwapTokenScreenTestTags.RECEIVE_CARD)) + hasTestTag(SwapTokenScreenTestTags.SELECT_TOKEN_ICON) + useUnmergedTree = true } fun swapTokenSymbol(symbol: String): KNode = child { @@ -191,6 +199,11 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(symbol) useUnmergedTree = true } + + val chooseTokenButton: KNode = child { + hasText(getResourceString(R.string.common_choose_token)) + useUnmergedTree = true + } } internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TesterMenuPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TesterMenuPageObject.kt new file mode 100644 index 0000000000..dce99464a3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TesterMenuPageObject.kt @@ -0,0 +1,39 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.feature.tester.impl.R as TesterImplR +import com.tangem.core.ui.test.BaseButtonTestTags +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 + +class TesterMenuPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val backButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val addressesInfoButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(TesterImplR.string.addresses_info)) + useUnmergedTree = true + } + + val jsonTab: KNode = child { + hasText("JSON") + useUnmergedTree = true + } + + val copyButton: KNode = child { + hasContentDescription("Copy") + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTesterMenuScreen(function: TesterMenuPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index dff98cfcee..4996f7db80 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -16,6 +16,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : @@ -192,6 +193,20 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasText(getResourceString(R.string.common_buy_currency, feeCurrencySymbol)) useUnmergedTree = true } + + fun expressStatusItem(title: String): KNode = child { + hasTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM) + hasAnyDescendant( + withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE) and withText(title) + ) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON)) + useUnmergedTree = true + } } internal fun BaseTestCase.onTokenDetailsScreen(function: TokenDetailsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt index db6413a7f2..5b84dda406 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt @@ -38,6 +38,18 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi hasText(getResourceString(R.string.settings_forget_wallet)) } + val accountsListContainer: KNode = walletSettingsItem.child { + hasTestTag(WalletSettingsScreenTestTags.ACCOUNTS_CONTAINER) + } + + val addAccountButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.account_form_create_button)) + } + + val openArchivedAccountsButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.account_archived_accounts)) + } + fun accountItem(accountName: String): KNode = walletSettingsItem.child { hasTestTag(WalletSettingsScreenTestTags.USER_ACCOUNT_ITEM) hasAnyDescendant(withText(accountName)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AccountDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountDetailsPageObject.kt similarity index 58% rename from app/src/androidTest/kotlin/com/tangem/screens/AccountDetailsPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountDetailsPageObject.kt index b347ee4785..99d9841489 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AccountDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountDetailsPageObject.kt @@ -1,8 +1,8 @@ -package com.tangem.screens +package com.tangem.screens.accounts import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.core.ui.test.AccountDetailsScreenTestTags +import com.tangem.core.ui.test.accounts.AccountDetailsScreenTestTags 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 @@ -11,6 +11,10 @@ import io.github.kakaocup.compose.node.element.KNode class AccountDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { + val screenContainer: KNode = child { + hasTestTag(AccountDetailsScreenTestTags.ACCOUNT_DETAILS_CONTAINER) + } + val topAppBarBackButton: KNode = child { hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) } @@ -18,7 +22,16 @@ class AccountDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi val manageTokensButton: KNode = child { hasTestTag(AccountDetailsScreenTestTags.MANAGE_TOKENS_BUTTON) } + + val archiveAccountButton: KNode = child { + hasTestTag(AccountDetailsScreenTestTags.ARCHIVE_ACCOUNT_BUTTON) + } + + val editAccountButton: KNode = child { + hasTestTag(AccountDetailsScreenTestTags.EDIT_ACCOUNT_BUTTON) + } + } -internal fun BaseTestCase.onAccountDetails(function: AccountDetailsPageObject.() -> Unit) = +internal fun BaseTestCase.onAccountDetailsScreen(function: AccountDetailsPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/accounts/ArchivedAccountsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/accounts/ArchivedAccountsPageObject.kt new file mode 100644 index 0000000000..5fd1d2cae7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/accounts/ArchivedAccountsPageObject.kt @@ -0,0 +1,70 @@ +package com.tangem.screens.accounts + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.test.accounts.AccountRowTestTags +import com.tangem.core.ui.test.accounts.ArchivedAccountsScreenTestTags +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.hasText as withText + +class ArchivedAccountsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val screenContainer: KNode = child { + hasTestTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNTS_SCREEN_CONTAINER) + } + + val topAppBarBackButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + } + + val topAppBarTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.account_archived_accounts)) + useUnmergedTree = true + } + + /** + * Returns a composite handle for a single archived account row, scoped by account name. + * All sub-elements (icon, title, tokens, networks, restore button) are children of this row. + */ + fun findArchivedAccountItemByName(accountName: String): ArchivedAccountRow { + val container: KNode = child { + hasTestTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNT_ITEM) + hasAnyDescendant(withText(accountName)) + useUnmergedTree = true + } + return ArchivedAccountRow(container) + } + + class ArchivedAccountRow(val container: KNode) { + + val icon: KNode = container.child { + hasTestTag(AccountRowTestTags.ICON) + useUnmergedTree = true + } + + val title: KNode = container.child { + hasTestTag(AccountRowTestTags.TITLE) + useUnmergedTree = true + } + + val subtitle: KNode = container.child { + hasTestTag(AccountRowTestTags.SUBTITLE) + useUnmergedTree = true + } + + val restoreButton: KNode = container.child { + hasTestTag(ArchivedAccountsScreenTestTags.RESTORE_BUTTON) + useUnmergedTree = true + } + } +} + +internal fun BaseTestCase.onArchivedAccountsScreen(function: ArchivedAccountsPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 7db589cb4a..b7fa3d9c5b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -8,6 +8,7 @@ import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWithAssertion import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.card.ScanFailsRequester import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import com.tangem.scenarios.checkFailedTransactionDialog @@ -17,7 +18,6 @@ import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.ThirdPartyAppPageObject import com.tangem.screens.onCreateWalletStartScreen import com.tangem.screens.onDetailsScreen -import com.tangem.screens.onDisclaimerScreen import com.tangem.screens.onFailedTransactionDialog import com.tangem.screens.onMainScreen import com.tangem.screens.onScanWarningDialog @@ -28,16 +28,18 @@ import com.tangem.screens.onStoriesScreen import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onMainScreenTopBar import com.tangem.tap.domain.sdk.mocks.MockProvider -import com.tangem.tap.store import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Ignore import org.junit.Test +import javax.inject.Inject @HiltAndroidTest class FeedbackTest : BaseTestCase() { + @Inject + lateinit var scanFailsRequester: ScanFailsRequester + @AllureId("894") @DisplayName("Send feedback: from details") @Test @@ -69,7 +71,6 @@ class FeedbackTest : BaseTestCase() { } } - @Ignore("TODO: [REDACTED_JIRA]") @AllureId("893") @DisplayName("Send feedback: failed transaction") @Test @@ -163,9 +164,6 @@ class FeedbackTest : BaseTestCase() { MockProvider.resetEmulateError() } ).run { - step("Click on 'Accept' button") { - onDisclaimerScreen { acceptButton.clickWithAssertion() } - } step("Set scanning error") { MockProvider.setEmulateError(TangemSdkError.TagLost()) } @@ -177,9 +175,8 @@ class FeedbackTest : BaseTestCase() { } step("Force show 'Scan warning' dialog"){ runOnUiThread { - val requester = store.state.daggerGraphState.scanFailsRequester!! MainScope().launch { - requester.show(AnalyticsParam.ScreensSources.Main) + scanFailsRequester.show(AnalyticsParam.ScreensSources.Main) } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt index 046964ff53..aa60f6632a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt @@ -10,6 +10,7 @@ import com.tangem.screens.onStoriesScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -18,6 +19,7 @@ class TermsOfServiceTest : BaseTestCase() { @AllureId("3573") @DisplayName("ToS: success acceptance") @Test + @Ignore("[REDACTED_JIRA]") fun validateTermsOfServiceScreenTest() { setupHooks().run { val tosUrl = "https://tangem.com/tangem_tos.html" @@ -46,6 +48,7 @@ class TermsOfServiceTest : BaseTestCase() { @AllureId("3574") @DisplayName("ToS: accept after app restart") @Test + @Ignore("[REDACTED_JIRA]") fun acceptTermsOfServiceAfterAppRestart() { val packageName = getTargetContext().packageName val tosUrl = "https://tangem.com/tangem_tos.html" diff --git a/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt new file mode 100644 index 0000000000..70e4d5d02a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt @@ -0,0 +1,253 @@ +package com.tangem.tests.accounts + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.REFERRAL_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.accounts.onAccountDetailsScreen +import com.tangem.screens.accounts.onArchivedAccountsScreen +import com.tangem.screens.onDialog +import com.tangem.screens.onWalletSettingsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class AccountArchivationsTest : BaseTestCase() { + + private val userTokensScenario = USER_TOKENS_API_SCENARIO + private val referralScenario = REFERRAL_API_SCENARIO + + @Test + @AllureId("5979") + @DisplayName("Accounts: Verify main account archivation button not available") + fun mainAccountArchivationAttemptTest() { + val mainAccountName = "Main account" + + setupHooks().run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $mainAccountName") { openAccountDetails(mainAccountName) } + step("Assert archive button is NOT displayed") { + onAccountDetailsScreen { archiveAccountButton.assertDoesNotExist() } + } + } + } + + @Test + @AllureId("5974") + @DisplayName("Accounts: archive a non-main account") + fun archiveSuccessfullyAccountTest() { + val accountToArchiveName = "Account 2" + val userAccountsState = "TwoAccountsArchivable" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $accountToArchiveName") { + openAccountDetails(accountToArchiveName) + } + + step("Assert 'Archive' button is displayed") { + onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } + } + step("Click on 'Archive' button") { + onAccountDetailsScreen { archiveAccountButton.clickWithAssertion() } + } + step("Verify 'Archive confirmation' dialog appeared with all elements") { + assertArchiveConfirmationDialog() + } + step("Click 'Archive' in confirmation menu") { + onDialog { archiveButton.clickWithAssertion() } + } + + step("Verify app returned to 'Wallet settings' screen") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Verify archived account '$accountToArchiveName' is no longer listed") { + onWalletSettingsScreen { + accountItem(accountToArchiveName).assertDoesNotExist() + } + } + } + } + + @Test + @AllureId("6844") + @DisplayName("Accounts: account archivation error on UI") + fun archiveAccountErrorTest() { + val accountToArchiveName = "Account 2" + val userAccountsState = "TwoAccountsArchivable" + val userAccountsErrorState = "AccountsPutError" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $accountToArchiveName") { + openAccountDetails(accountToArchiveName) + } + + step("Forcing network error scenario") { + setWireMockScenarioState(userTokensScenario, userAccountsErrorState) + } + step("Attempt to archive the account") { archiveAccount() } + step("Assert error dialog details") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.common_something_went_wrong), + expectedMessage = getResourceString(R.string.account_generic_error_dialog_message), + ) + } + } + } + + @Test + @AllureId("5981") + @DisplayName("Accounts: archive account with referral program error") + fun archiveAccountReferralErrorTest() { + val accountToArchiveName = "Account 2" + val userAccountsState = "TwoAccountsArchivableAndReferral" + val referralActiveState = "Participating" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsState) + setWireMockScenarioState(referralScenario, referralActiveState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + resetWireMockScenarioState(referralScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $accountToArchiveName") { + openAccountDetails(accountToArchiveName) + } + step("Attempt to archive the account") { archiveAccount() } + + step("Assert error dialog details") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.account_could_not_archive_referral_program_title), + expectedMessage = getResourceString(R.string.account_could_not_archive_referral_program_message), + ) + } + step("Dismiss error dialog") { dismissErrorDialog() } + step("Assert 'Archive' button is still visible after error") { + onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } + } + } + } + + @Test + @AllureId("5976") + @DisplayName("Accounts: restore an archived account") + fun restoreArchivedAccountTest() { + val archivedAccountName = "Account 3" + val userAccountsInitialState = "TwoAccountsWithArchivedAccounts" + val userAccountsAfterArchivationState = "ReadyToRestore" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsInitialState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() } + step("Verify archived wallet account with name '$archivedAccountName' is present") { + assertArchivedAccountIsDisplayed(archivedAccountName) + } + + step("Switch WireMock to '$userAccountsAfterArchivationState' users scenario state") { + setWireMockScenarioState(userTokensScenario, userAccountsAfterArchivationState) + } + step("Restore account with name '$archivedAccountName'") { + restoreArchivedAccount(archivedAccountName) + } + + step("Assert 'Wallet settings' screen is displayed") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Assert restored account '$archivedAccountName' appears in 'Active accounts' list") { + onWalletSettingsScreen { accountItem(archivedAccountName).assertIsDisplayed() } + } + } + } + + @Test + @AllureId("7962") + @DisplayName("Accounts: restore archived account error") + fun restoreArchivedAccountErrorTest() { + val archivedAccountName = "Account 3" + val userAccountsInitialState = "TwoAccountsWithArchivedAccounts" + val userAccountsRestorationErrorState = "AccountsPutError" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsInitialState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() } + step("Verify archived wallet account with name '$archivedAccountName' is present") { + assertArchivedAccountIsDisplayed(archivedAccountName) + } + + step("Switch WireMock to '$userAccountsRestorationErrorState' user" + + "accounts scenario state to simulate restoration failure") { + setWireMockScenarioState(userTokensScenario, userAccountsRestorationErrorState) + } + step("Attempt restore account with name '$archivedAccountName'") { + restoreArchivedAccount(archivedAccountName) + } + + step("Assert error dialog details") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.common_something_went_wrong), + expectedMessage = getResourceString(R.string.account_generic_error_dialog_message), + ) + } + step("Dismiss error dialog") { dismissErrorDialog() } + + step("Assert archived account '$archivedAccountName' is still in archived list") { + onArchivedAccountsScreen { + findArchivedAccountItemByName(archivedAccountName) + .container.assertIsDisplayed() + } + } + } + } +} diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 2cb635f684..1f795cadce 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -21,7 +21,6 @@ import com.tangem.tap.domain.sdk.mocks.content.TwinsMockContent import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -462,7 +461,6 @@ class MainScreenActionButtonsTest : BaseTestCase() { } } - @Ignore("TODO: [REDACTED_JIRA]") @AllureId("4396") @DisplayName("Action buttons (main screen): click on buttons without data") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AddressesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AddressesTest.kt new file mode 100644 index 0000000000..46695fa68b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AddressesTest.kt @@ -0,0 +1,86 @@ +package com.tangem.tests.hotWallet + +import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig +import com.tangem.common.constants.TestConstants.SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_15 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_18 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_21 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_24 +import com.tangem.common.utils.checkServiceHealth +import com.tangem.common.utils.getAddressesFromApi +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.scenarios.verifyAddresses +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Assert.assertNotNull +import org.junit.Test + +@HiltAndroidTest +class AddressesTest : BaseTestCase() { + + private var apiAddressesJson: String? = null + + private fun setupAddressTestHooks(seedKey: String) = setupHooks( + additionalBeforeAppLaunchSection = { + val status = checkServiceHealth() + assertNotNull("QA tools service is unreachable ([REDACTED_ENV_URL]", status) + + apiAddressesJson = getAddressesFromApi(seedKey) + assertNotNull("Failed to fetch reference addresses for '$seedKey'", apiAddressesJson) + }, + ) + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("1792") + @DisplayName("Hot wallet: auto derivation addresses for seed 12") + @Test + fun seed12AddressesTest() { + setupAddressTestHooks("twelve").run { + verifyAddresses(SEED_PHRASE_12, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5106") + @DisplayName("Hot wallet: auto derivation addresses for seed 15") + @Test + fun seed15AddressesTest() { + setupAddressTestHooks("fifteen").run { + verifyAddresses(SEED_PHRASE_15, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5107") + @DisplayName("Hot wallet: auto derivation addresses for seed 18") + @Test + fun seed18AddressesTest() { + setupAddressTestHooks("eighteen").run { + verifyAddresses(SEED_PHRASE_18, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5108") + @DisplayName("Hot wallet: auto derivation addresses for seed 21") + @Test + fun seed21AddressesTest() { + setupAddressTestHooks("twenty_one").run { + verifyAddresses(SEED_PHRASE_21, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5109") + @DisplayName("Hot wallet: auto derivation addresses for seed 24") + @Test + fun seed24AddressesTest() { + setupAddressTestHooks("twenty_four").run { + verifyAddresses(SEED_PHRASE_24, requireNotNull(apiAddressesJson)) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt index a69ee3c9f4..a576c9e327 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt @@ -9,6 +9,7 @@ import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* +import com.tangem.screens.accounts.onAccountDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -93,7 +94,7 @@ class HideTokenTest : BaseTestCase() { onWalletSettingsScreen { accountItem(accountName).performClick() } } step("Click on 'Manage tokens' button") { - onAccountDetails { manageTokensButton.performClick() } + onAccountDetailsScreen { manageTokensButton.performClick() } } step("Click on token: '$tokenTitle'") { onManageTokensScreen { tokenItem(tokenTitle).performClick() } @@ -118,7 +119,7 @@ class HideTokenTest : BaseTestCase() { } step("Click on 'Account details' screen 'Back' button") { waitForIdle() - onAccountDetails { topAppBarBackButton.performClick() } + onAccountDetailsScreen { topAppBarBackButton.performClick() } } step("Click on 'Wallet settings' screen 'Back' button") { waitForIdle() @@ -162,7 +163,7 @@ class HideTokenTest : BaseTestCase() { onWalletSettingsScreen { accountItem(accountName).performClick() } } step("Click on 'Manage tokens' button") { - onAccountDetails { manageTokensButton.performClick() } + onAccountDetailsScreen { manageTokensButton.performClick() } } step("Click on token: '$tokenTitle'") { onManageTokensScreen { tokenItem(tokenTitle).performClick() } @@ -187,7 +188,7 @@ class HideTokenTest : BaseTestCase() { } step("Click on 'Account details' screen 'Back' button") { waitForIdle() - onAccountDetails { topAppBarBackButton.performClick() } + onAccountDetailsScreen { topAppBarBackButton.performClick() } } step("Click on 'Wallet settings' screen 'Back' button") { waitForIdle() @@ -308,5 +309,4 @@ class HideTokenTest : BaseTestCase() { } } } - } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt index 22f4645cf3..81e533d007 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt @@ -2,15 +2,20 @@ package com.tangem.tests.send.sendViaSwap import com.tangem.common.BaseTestCase import com.tangem.common.R -import com.tangem.common.extensions.extractText import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.POLYGON_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.extractText +import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.scenarios.openSendConfirmScreenViaNextButton -import com.tangem.scenarios.openSendScreen +import com.tangem.scenarios.* import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.AllureId @@ -30,6 +35,7 @@ class SendViaSwapTest : BaseTestCase() { val bitcoinBalanceScenarioState = "Balance" val assetsScenarioName = "express_api_assets" val assetsScenarioState = "BitcoinExchangeEnabled" + val userTokensScenarioState = "Wallet2" val warningTitle = getResourceString(R.string.express_swap_not_supported_title, stellar) val warningMessage = getResourceString(R.string.express_swap_not_supported_text) @@ -37,6 +43,7 @@ class SendViaSwapTest : BaseTestCase() { additionalAfterSection = { resetWireMockScenarioState(bitcoinBalanceScenarioName) resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { @@ -46,9 +53,11 @@ class SendViaSwapTest : BaseTestCase() { step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) } - + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensScenarioState) + } step("Open 'Send' screen") { - openSendScreen(tokenName) + openSendScreen(tokenName, mockContent = Wallet2WithDerivationsMockContent) } step("Click on 'Swap to another token button'") { onSendScreen { swapToAnotherTokenButton.performClick() } @@ -89,11 +98,13 @@ class SendViaSwapTest : BaseTestCase() { val bitcoinBalanceScenarioState = "Balance" val assetsScenarioName = "express_api_assets" val assetsScenarioState = "BitcoinExchangeEnabled" + val userTokensScenarioState = "Wallet2" setupHooks( additionalAfterSection = { resetWireMockScenarioState(bitcoinBalanceScenarioName) resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { @@ -103,9 +114,12 @@ class SendViaSwapTest : BaseTestCase() { step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensScenarioState) + } step("Open 'Send' screen") { - openSendScreen(tokenName) + openSendScreen(tokenName, mockContent = Wallet2WithDerivationsMockContent) } step("Type '$firstInputAmount' in text field") { onSendScreen { amountInputTextField.performTextInput(firstInputAmount) } @@ -174,10 +188,13 @@ class SendViaSwapTest : BaseTestCase() { step("Assert provider name is '$regularProviderName'") { onSendConfirmScreen { providerName.assertTextContains(regularProviderName) } } - step("Click on fee selector icon") { - onSendConfirmScreen { feeSelectorIcon.performClick() } + step("Open fee selector bottom sheet via click on fee selector icon") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeSelectorIcon.performClick() } + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } + } } - step("Click on '$fastSelectorItem' selector item is displayed") { + step("Click on '$fastSelectorItem' selector item") { onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).performClick() } } step("Capture fast fee value") { @@ -228,6 +245,7 @@ class SendViaSwapTest : BaseTestCase() { val bitcoinBalanceScenarioState = "Balance" val assetsScenarioName = "express_api_assets" val assetsScenarioState = "BitcoinExchangeEnabled" + val userTokensScenarioState = "Wallet2" val dialogTitle = getResourceString(R.string.send_with_swap_change_token_alert_title) val dialogMessage = getResourceString(R.string.send_with_swap_change_token_alert_message) val addressHint = getResourceString(R.string.send_enter_address_field_ens) @@ -236,6 +254,7 @@ class SendViaSwapTest : BaseTestCase() { additionalAfterSection = { resetWireMockScenarioState(bitcoinBalanceScenarioName) resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { @@ -245,9 +264,12 @@ class SendViaSwapTest : BaseTestCase() { step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensScenarioState) + } step("Open 'Send' screen") { - openSendScreen(tokenName) + openSendScreen(tokenName, mockContent = Wallet2WithDerivationsMockContent) } step("Type '$inputAmount' in text field") { onSendScreen { amountInputTextField.performTextInput(inputAmount) } @@ -369,4 +391,278 @@ class SendViaSwapTest : BaseTestCase() { } } } + + @AllureId("3967") + @DisplayName("Send via Swap: full successful send via swap flow") + @Test + fun sendViaSwapSuccessfulFlowTest() { + val tokenName = "Bitcoin" + val swapTokenName = "Ethereum" + val main = "MAIN" + val inputAmount = "0.001" + val providerName = "SimpleSwap" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + val bitcoinBalanceScenarioName = "bitcoin_utxo" + val bitcoinBalanceScenarioState = "BalanceHotWalletSvS" + val assetsScenarioName = "express_api_assets" + val assetsScenarioState = "BitcoinExchangeEnabled" + val hotWalletScenarioState = "HotWalletSvS" + val providersScenarioName = "networks_providers" + val providersScenarioState = "HotWalletSvS" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(bitcoinBalanceScenarioName) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(providersScenarioName) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$bitcoinBalanceScenarioName' to state: '$bitcoinBalanceScenarioState'") { + setWireMockScenarioState(scenarioName = bitcoinBalanceScenarioName, state = bitcoinBalanceScenarioState) + } + step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) + } + step("Set WireMock scenario: '$providersScenarioName' to state: '$providersScenarioState'") { + setWireMockScenarioState(scenarioName = providersScenarioName, state = providersScenarioState) + } + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select token to Send via Swap") { + selectTokenToSendViaSwap(swapTokenName = swapTokenName, networkName = swapTokenName, networkType = main) + } + step("Type '$inputAmount' in text field") { + onSendScreen { amountInputTextField.performTextInput(inputAmount) } + } + step("Click on 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) } + } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } + } + step("Assert 'Best rate' badge is displayed") { + onSendConfirmScreen { bestRateBadge.assertIsDisplayed() } + } + step("Open 'Send via swap success' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Explore' button") { + onSendSuccessScreen { exploreButton.performClick() } + } + step("Assert Chrome Browser is opened") { + ThirdPartyAppPageObject { assertChromeIsOpened() } + } + step("Press 'Back' button to close 'Chrome' browser") { + device.uiDevice.pressBack() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item is displayed with title: '$expressStatusItemTitle'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } + + @AllureId("4017") + @DisplayName("Send via Swap: send same token in different network") + @Test + fun sendSameTokenInDifferentNetworkTest() { + val tokenName = "Tether" + val swapTokenName = "Tether" + val networkName = "Polygon" + val inputAmount = "0.001" + val ethCallScenarioName = "eth_call_api" + val ethCallScenarioState = "Started" + val hotWalletScenarioState = "USDTHotWalletSvS" + val ethNetworkBalanceScenarioName = "eth_network_balance" + val ethNetworkBalanceScenarioState = "Started" + val providerName = "Changelly" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(ethCallScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(ethNetworkBalanceScenarioName) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$ethCallScenarioName' to state: '$ethCallScenarioState'") { + setWireMockScenarioState(scenarioName = ethCallScenarioName, state = ethCallScenarioState) + } + step("Set WireMock scenario: '$ethNetworkBalanceScenarioName' to state: '$ethNetworkBalanceScenarioState'") { + setWireMockScenarioState(scenarioName = ethNetworkBalanceScenarioName, state = ethNetworkBalanceScenarioState) + } + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select token to Send via Swap") { + selectTokenToSendViaSwap(swapTokenName = swapTokenName, networkName = networkName) + } + step("Type '$inputAmount' in text field") { + onSendScreen { amountInputTextField.performTextInput(inputAmount) } + } + step("Click on 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(POLYGON_RECIPIENT_ADDRESS) } + } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } + } + step("Assert 'Best rate' badge is displayed") { + onSendConfirmScreen { bestRateBadge.assertIsDisplayed() } + } + step("Open 'Send via swap success' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item is displayed with title: '$expressStatusItemTitle'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } + + @AllureId("4545") + @DisplayName("Send via Swap: token with tag/memo send via swap flow") + @Test + fun sendViaSwapTokenWithTagTest() { + val tokenName = "XRP Ledger" + val swapTokenName = "Ethereum" + val main = "MAIN" + val inputAmount = "0.001" + val providerName = "Changelly" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + val hotWalletScenarioState = "XRPHotWalletSvS" + val xrpExchangeQuoteScenarioName = "xrp_exchange_quote" + val xrpExchangeDataScenarioName = "xrp_exchange_data" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(xrpExchangeQuoteScenarioName) + resetWireMockScenarioState(xrpExchangeDataScenarioName) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$xrpExchangeQuoteScenarioName' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = xrpExchangeQuoteScenarioName, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$xrpExchangeDataScenarioName' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = xrpExchangeDataScenarioName, state = hotWalletScenarioState) + } + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select token to Send via Swap") { + selectTokenToSendViaSwap(swapTokenName = swapTokenName, networkName = swapTokenName, networkType = main) + } + step("Type '$inputAmount' in text field") { + onSendScreen { amountInputTextField.performTextInput(inputAmount) } + } + step("Click on 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) } + } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } + } + step("Open 'Send via swap success' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item is displayed with title: '$expressStatusItemTitle'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CardanoWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CardanoWarningsTest.kt new file mode 100644 index 0000000000..e7137a0293 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CardanoWarningsTest.kt @@ -0,0 +1,151 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.CARDANO_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class CardanoWarningsTest : BaseTestCase() { + private val tokenName = "Cardano" + private val minAmount = "ADA 1.00" + + private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title) + private val invalidAmountMessage = + getResourceString(R.string.send_notification_invalid_minimum_amount_text, minAmount, minAmount) + + @AllureId("4204") + @DisplayName("Warnings: check warning, when remains less than 1 ADA") + @Test + fun afterTransactionRemainsLessThanMinimumAmountTest() { + val sendAmount = "19" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount' warning is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = true + ) + } + } + } + + @AllureId("4207") + @DisplayName("Warnings: check warning, when amount more than 1 ADA") + @Test + fun transactionAmountMoreThanOneTest() { + val sendAmount = "2.5" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount' warning is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4210") + @DisplayName("Warnings: check warning, when remains more than 1 ADA") + @Test + fun afterTransactionRemainsMoreThanMinimumAmountTest() { + val sendAmount = "18" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount' warning is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CommonWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CommonWarningsTest.kt new file mode 100644 index 0000000000..7ba361bbbd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CommonWarningsTest.kt @@ -0,0 +1,160 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openSendConfirmScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendSelectNetworkFeeBottomSheet +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class CommonWarningsTest : BaseTestCase() { + + @AllureId("4293") + @DisplayName("Warnings: check warning, when custom fee lower than 'Slow'") + @Test + fun warningDisplayedWhenCustomFeeLowerThanSlowTest() { + val tokenName = "Ethereum" + val sendAmount = "0.01" + val feeUpTo = getResourceString(R.string.send_max_fee) + val customFee = "0.000000001" + val warningTitle = getResourceString(R.string.send_notification_transaction_delay_title) + val warningMessage = getResourceString(R.string.send_notification_transaction_delay_text) + + setupHooks().run { + + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Confirm' screen with token: $tokenName") { + openSendConfirmScreen(tokenName, sendAmount, ETHEREUM_RECIPIENT_ADDRESS) + } + step("Click on fee selector icon") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSendConfirmScreen { feeSelectorIcon.performClick() } + onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() } + } + } + step("Click on 'Custom' selector item") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() } + } + step("Type '$customFee' into input text field") { + onSendSelectNetworkFeeBottomSheet { + inputTextFieldValue(title = feeUpTo).performClick() + inputTextFieldValue(title = feeUpTo).performTextReplacement(customFee) + } + } + step("Click on 'Done' button") { + onSendSelectNetworkFeeBottomSheet { doneButton.performClick() } + } + step("Assert 'Transaction delays are possible' warning is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = true, + sendButtonIsDisabled = false + ) + } + } + } + + @AllureId("4221") + @DisplayName("Warnings: check warning, when amount exceeds total balance") + @Test + fun warningDisplayedWhenAmountExceedsTotalBalanceTest() { + val tokenName = "Ethereum" + val sendAmount = "0.9999" + val network = "ETH 0.00032" + val amount = "\$0.81" + val warningTitle = getResourceString(R.string.send_network_fee_warning_title) + val warningMessage = getResourceString(R.string.common_network_fee_warning_content, network, amount) + + setupHooks().run { + + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + synchronizeAddresses() + } + } + step("Open 'Send Confirm' screen with token: $tokenName") { + openSendConfirmScreen(tokenName, sendAmount, ETHEREUM_RECIPIENT_ADDRESS) + } + step("Assert 'Network fee coverage' warning is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = true, + sendButtonIsDisabled = false + ) + } + } + } + + @AllureId("4294") + @DisplayName("Warnings: check warning, when custom fee is high") + @Test + fun warningDisplayedWhenCustomFeeIsHighTest() { + val tokenName = "Ethereum" + val sendAmount = "0.01" + val feeUpTo = getResourceString(R.string.send_max_fee) + val customFee = "0.004" + val timesHigher = "7" + val warningTitle = getResourceString(R.string.send_notification_fee_too_high_title) + val warningMessage = getResourceString(R.string.send_notification_fee_too_high_text, timesHigher) + + setupHooks().run { + + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Confirm' screen with token: $tokenName") { + openSendConfirmScreen(tokenName, sendAmount, ETHEREUM_RECIPIENT_ADDRESS) + } + step("Click on fee selector icon") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSendConfirmScreen { feeSelectorIcon.performClick() } + onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() } + } + } + step("Click on 'Custom' selector item") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() } + } + step("Type '$customFee' into input text field") { + onSendSelectNetworkFeeBottomSheet { + inputTextFieldValue(title = feeUpTo).performClick() + inputTextFieldValue(title = feeUpTo).performTextReplacement(customFee) + } + } + step("Click on 'Done' button") { + onSendSelectNetworkFeeBottomSheet { doneButton.performClick() } + } + step("Assert 'Custom fee is high' warning is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = true, + sendButtonIsDisabled = false + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt index e1e79b96ad..5dba4d5d87 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt @@ -238,20 +238,17 @@ class SearchAndSwapTest : BaseTestCase() { onSwapTokenScreen { replaceTokensButton.performClick() } } step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + onSwapTokenScreen { swapSelectTokenIcon.performClick() } } step("Click on 'Search' icon") { - onSwapChooseTokenScreen { searchIcon.performClick() } - } - step("Click on 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performClick() } + onSwapSelectTokenScreen { searchBarIcon.performClick() } } step("Type '$swapTokenSymbol' in 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performTextInputInChunks(swapTokenSymbol) } + onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(swapTokenSymbol) } } step("Click on token with name: '$swapTokenName'") { flakySafely(WAIT_UNTIL_TIMEOUT) { - onSwapChooseTokenScreen { marketsTokenWithTitle(swapTokenName).performClick() } + onSwapSelectTokenScreen { marketsTokenWithName(swapTokenName).performClick() } } } step("Click on 'Add' button") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt index f84fdea929..def2f12334 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt @@ -4,7 +4,7 @@ import com.tangem.common.BaseTestCase import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnvConfig import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO -import com.tangem.common.extensions.* +import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.datasource.api.common.config.ApiConfig @@ -74,22 +74,22 @@ class SwapChooseTokenScreenTest : BaseTestCase() { } } step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + onSwapTokenScreen { swapSelectTokenIcon.performClick() } } step("Assert '$ethereum' is displayed") { - onSwapChooseTokenScreen { tokenWithTitle(ethereum).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(ethereum).assertIsDisplayed() } } step("Assert '$polExMatic' is displayed") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).assertIsDisplayed() } } step("Assert '$bitcoin' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(bitcoin).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(bitcoin).assertIsNotDisplayed() } } step("Assert '$jesusCoin' is displayed and unavailable for swap") { - onSwapChooseTokenScreen { tokenWithTitle(tokenTitle = jesusCoin).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(jesusCoin).assertIsDisplayed() } } step("Assert custom token without backend id '$salam' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(salam).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(salam).assertIsNotDisplayed() } } } } @@ -136,38 +136,35 @@ class SwapChooseTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } } step("Click on 'Search' icon") { - onSwapChooseTokenScreen { searchIcon.performClick() } - } - step("Click on 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performClick() } + onSwapSelectTokenScreen { searchBarIcon.performClick() } } step("Type invalid search text: '$invalidSearchText' in 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performTextReplacement(invalidSearchText) } + onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(invalidSearchText) } } step("Assert '$ethereum' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(ethereum).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(ethereum).assertIsNotDisplayed() } } step("Assert '$polExMatic' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).assertIsNotDisplayed() } } step("Press 'Delete' button") { device.uiDevice.pressDelete() } step("Type valid search text: '$validSearchText' in 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performTextReplacement(validSearchText) } + onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(validSearchText) } } step("Assert '$ethereum' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(ethereum).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(ethereum).assertIsNotDisplayed() } } step("Assert '$polExMatic' is displayed") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).assertIsDisplayed() } } step("Select new receive token: $polExMatic") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).performClick() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).performClick() } } step("Assert new receive token symbol: '$polExMaticSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(polExMaticSymbol).assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapMainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapMainScreenTest.kt new file mode 100644 index 0000000000..678973c9d2 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapMainScreenTest.kt @@ -0,0 +1,38 @@ +package com.tangem.tests.swap + +import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.openMainScreen +import com.tangem.screens.onMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SwapMainScreenTest : BaseTestCase() { + + @ApiEnv( + ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD), + ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) + ) + @AllureId("574") + @DisplayName("Swap: 'Swap' button is not displayed for single currency card") + @Test + fun singleTokenNoteCardScanTest() { + val cardType: ProductType = ProductType.Note + + setupHooks().run { + step("Open 'Main Screen' on '${cardType.name}' card") { + openMainScreen(cardType) + } + step("Assert 'Swap' button is not displayed") { + onMainScreen { swapButton.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt index e474212036..cc654f9b2c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt @@ -20,7 +20,6 @@ class SwapSelectTokenScreenTest : BaseTestCase() { @DisplayName("Open 'Swap select token' screen from 'Main' screen") @Test fun openSwapSelectTokenScreenFromMainScreenTest() { - val swapTokenName = "Ethereum" val receiveTokenName = "Polygon" setupHooks().run { @@ -37,14 +36,11 @@ class SwapSelectTokenScreenTest : BaseTestCase() { step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } } - step("Assert 'Swap select token' screen title is displayed") { - onSwapSelectTokenScreen { title.assertIsDisplayed() } + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } } - step("Assert 'You swap' title is displayed") { - onSwapSelectTokenScreen { youSwapTitle.assertIsDisplayed() } - } - step("Assert 'You swap' block is displayed") { - onSwapSelectTokenScreen { youSwapBlock.assertIsDisplayed() } + step("Assert 'You receive' title is displayed") { + onSwapSelectTokenScreen { youReceiveTitle.assertIsDisplayed() } } step("Assert search icon is displayed") { onSwapSelectTokenScreen { searchBarIcon.assertIsDisplayed() } @@ -52,15 +48,6 @@ class SwapSelectTokenScreenTest : BaseTestCase() { step("Assert search placeholder is displayed") { onSwapSelectTokenScreen { searchBarPlaceholderText.assertIsDisplayed() } } - step("Click on token with name '$swapTokenName'") { - onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() } - } - step("Assert 'You receive' title is displayed") { - onSwapSelectTokenScreen { youReceiveTitle.assertIsDisplayed() } - } - step("Assert 'You receive' block is displayed") { - onSwapSelectTokenScreen { youReceiveBlock.assertIsDisplayed() } - } step("Click on token with name '$receiveTokenName'") { onSwapSelectTokenScreen { tokenWithName(receiveTokenName).performClick() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index 22046cdc8a..0336d3895d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -32,6 +32,7 @@ class SwapTokenScreenTest : BaseTestCase() { fun networkFeeTest() { val inputAmount = "400" val tokenTitle = "Polygon" + val receiveTokenName = "Ethereum" setupHooks().run { @@ -67,13 +68,6 @@ class SwapTokenScreenTest : BaseTestCase() { } } } - step("Assert receive amount is displayed") { - onSwapTokenScreen { - flakySafely(WAIT_UNTIL_TIMEOUT) { - receiveAmount.assertIsDisplayed() - } - } - } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -81,9 +75,19 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert input amount = '$inputAmount'") { onSwapTokenScreen { textInput.assertTextEquals(inputAmount) } } + step("Assert receive amount is displayed") { + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT) { + receiveAmount.assertIsDisplayed() + } + } + } step("Assert 'Providers' block is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { @@ -175,9 +179,10 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun changeNetworkFeeTest() { val inputAmount = "400" + val tokenTitle = "Polygon" + val receiveTokenName = "Ethereum" setupHooks().run { - val tokenTitle = "Polygon" step("Open 'Main Screen'") { openMainScreen() @@ -207,13 +212,6 @@ class SwapTokenScreenTest : BaseTestCase() { } } } - step("Assert receive amount is displayed") { - onSwapTokenScreen { - flakySafely(WAIT_UNTIL_TIMEOUT) { - receiveAmount.assertIsDisplayed() - } - } - } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -224,6 +222,16 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert input amount = '$inputAmount'") { onSwapTokenScreen { textInput.assertTextEquals(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } + step("Assert receive amount is displayed") { + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT) { + receiveAmount.assertIsDisplayed() + } + } + } step("Assert 'Network fee' block is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { @@ -270,11 +278,10 @@ class SwapTokenScreenTest : BaseTestCase() { ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) ) @AllureId("2828") - @DisplayName("Swap: network fee") + @DisplayName("Swap: go to token swap") @Test fun goToTokenSwapTest() { val swapTokenSymbol = "POL" - val receiveTokenSymbol = "ETH" val tokenTitle = "Polygon" setupHooks().run { @@ -314,8 +321,8 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } } - step("Assert token symbol: '$receiveTokenSymbol' is displayed") { - onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } + step("Assert 'Choose token' button is displayed") { + onSwapTokenScreen { chooseTokenButton.assertIsDisplayed() } } } } @@ -329,6 +336,7 @@ class SwapTokenScreenTest : BaseTestCase() { fun checkSwapUiTest() { val swapTokenSymbol = "POL" val receiveTokenSymbol = "ETH" + val receiveTokenName = "Ethereum" val newReceiveToken = "POL (ex-MATIC)" val tokenTitle = "Polygon" val inputAmount = "1" @@ -356,14 +364,17 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert swap token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } } - step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + step("Click on receive 'Select token' icon") { + onSwapTokenScreen { receiveSelectTokenIcon.performClick() } } step("Select new receive token: $newReceiveToken") { - onSwapChooseTokenScreen { tokenWithTitle(newReceiveToken).performClick() } + onSwapSelectTokenScreen { tokenWithName(newReceiveToken).performClick() } } step("Assert new receive token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(swapTokenSymbol).assertIsDisplayed() } @@ -392,7 +403,11 @@ class SwapTokenScreenTest : BaseTestCase() { onSwapTokenScreen { swapFiatAmount.assertIsDisplayed() } } step("Assert receive token fiat amount is displayed") { - onSwapTokenScreen { receiveFiatAmount.assertIsDisplayed() } + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + receiveFiatAmount.assertIsDisplayed() + } + } } step("Assert 'Swap tokens on screen' button is displayed") { onSwapTokenScreen { @@ -416,6 +431,7 @@ class SwapTokenScreenTest : BaseTestCase() { fun checkSwapTokensSwitchTest() { val swapTokenSymbol = "POL" val receiveTokenSymbol = "ETH" + val receiveTokenName = "Ethereum" val tokenTitle = "Polygon" setupHooks().run { @@ -438,6 +454,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert swap token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } } @@ -514,6 +533,7 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun enableToCoverMarketAndFastFeeTest() { val tokenName = "Ethereum" + val receiveTokenName = "Polygon" val inputAmount = "0.99" val market = "Market" val fast = "Fast" @@ -544,6 +564,9 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Select '$market' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { selectFeeType(FeeType.Market, selectedFeeAmount = marketFeeAmount) @@ -562,6 +585,7 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun unableToCoverMarketAndFastFeeTest() { val tokenName = "POL (ex-MATIC)" + val receiveTokenName = "Ethereum" val inputAmount = "0.0001" val marketFeeType = "Market" val fastFeeType = "Fast" @@ -587,6 +611,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } @@ -603,6 +630,9 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Select '$marketFeeType' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { selectFeeType(feeType = FeeType.Market, feeAmount) @@ -633,6 +663,7 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun unableToCoverFastFeeTest() { val tokenName = "POL (ex-MATIC)" + val receiveTokenName = "Ethereum" val inputAmount = "3000" val fastFeeType = "Fast" val fastFeeAmount = "$2," @@ -660,6 +691,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } @@ -676,6 +710,9 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Swap' button is enabled") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onSwapTokenScreen { swapButton.assertIsEnabled() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt index e61314adb3..3b220ce5d5 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt @@ -15,6 +15,7 @@ import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.scenarios.SwapEntryPoint import com.tangem.scenarios.chackUnableToCoverFeeNotification import com.tangem.scenarios.checkSwapWarning +import com.tangem.scenarios.chooseReceiveToken import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openSwapScreen import com.tangem.scenarios.synchronizeAddresses @@ -36,6 +37,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun checkSwapInsufficientFundsWarningTest() { val tokenTitle = "Polygon" + val receiveTokenTitle = "Ethereum" val inputAmount = "1000" setupHooks().run { @@ -65,6 +67,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenTitle) + } step("Assert 'Insufficient funds' error is displayed") { waitForIdle() onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } @@ -127,6 +132,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(networkName) + } step("Check 'Unable to cover '$networkName' fee notification") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { chackUnableToCoverFeeNotification(networkName = networkName, currencySymbol = currencySymbol) @@ -143,6 +151,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun checkHighPriceImpactWarningCEXTest() { val tokenTitle = "USDC" + val receiveTokenName = "Solana" val inputAmount = "100" val currencySymbol = "SOL" val slippagePercent = "5%" @@ -202,6 +211,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert fiat amount with warning is displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onSwapTokenScreen { @@ -236,6 +248,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun checkHighPriceImpactWarningDEXTest() { val tokenTitle = "Polygon" + val receiveTokenName = "Ethereum" val inputAmount = "1000" val slippagePercent = "3.5%" val dialogTitle = getResourceString(R.string.swapping_alert_title) @@ -287,6 +300,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert fiat amount with warning is displayed") { onSwapTokenScreen { receiveFiatAmount.assertTextContains("%", substring = true) } } @@ -323,6 +339,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceEqualToZeroWarningTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.00168933" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -371,6 +388,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -388,6 +408,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceEqualToRentAmountTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.001689338" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -436,6 +457,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -454,6 +478,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceMoreThanRentAmountTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.0000941" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -502,6 +527,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -519,6 +547,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceLessThanRentAmountTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.0016941" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -567,6 +596,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2ef3c24014..27f8c3c8e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -232,6 +232,17 @@ android:scheme="tangem" /> + + + + + + + + + @@ -285,6 +296,16 @@ android:host="onboard-visa" android:scheme="tangem" /> + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 957e21f3dd..4c7aa189cb 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -1,157 +1,52 @@ package com.tangem.tap import androidx.hilt.work.HiltWorkerFactory -import com.tangem.TangemSdkLogger -import com.tangem.blockchainsdk.BlockchainSDKFactory -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.common.routing.AppRouter import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.filter.OneTimeEventFilter -import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.decompose.di.GlobalUiMessageSender -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.navigation.share.ShareManager -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.data.card.TransactionSignerFactory import com.tangem.datasource.api.common.config.managers.ApiConfigsManager -import com.tangem.datasource.connection.NetworkConnectionManager import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.datasource.local.config.issuers.IssuersConfigStorage -import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.GetAppThemeModeUseCase -import com.tangem.domain.apptheme.repository.AppThemeModeRepository -import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository -import com.tangem.domain.card.ScanCardProcessor -import com.tangem.domain.card.ScanFailsRequester -import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetWalletMetaInfoUseCase -import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.repository.OnboardingRepository -import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles -import com.tangem.hot.sdk.TangemHotSdk -import com.tangem.tap.common.analytics.CustomerIoFeatureToggles import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient -import com.tangem.tap.common.log.TangemAppLoggerInitializer -import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.proxy.AppStateHolder +import com.tangem.tap.common.log.TangemLoggingInitializer import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @EntryPoint @InstallIn(SingletonComponent::class) -@Suppress("TooManyFunctions") interface ApplicationEntryPoint { - fun getAppStateHolder(): AppStateHolder - - fun getIssuersConfigStorage(): IssuersConfigStorage - fun getEnvironmentConfig(): EnvironmentConfig fun getFeatureTogglesManager(): FeatureTogglesManager fun getExcludedBlockchainsManager(): ExcludedBlockchainsManager - fun getNetworkConnectionManager(): NetworkConnectionManager - - fun getCardScanningFeatureToggles(): CardScanningFeatureToggles - - fun getScanCardProcessor(): ScanCardProcessor - - fun getAppCurrencyRepository(): AppCurrencyRepository - - fun getWalletManagersFacade(): WalletManagersFacade - - fun getAppThemeModeRepository(): AppThemeModeRepository - - fun getBalanceHidingRepository(): BalanceHidingRepository - - fun getAppPreferencesStore(): AppPreferencesStore - fun getGetAppThemeModeUseCase(): GetAppThemeModeUseCase fun getWalletsRepository(): WalletsRepository fun getOneTimeEventFilter(): OneTimeEventFilter - fun getWasTwinsOnboardingShownUseCase(): WasTwinsOnboardingShownUseCase - - fun getSaveTwinsOnboardingShownUseCase(): SaveTwinsOnboardingShownUseCase - - fun getCardRepository(): CardRepository - - fun getTangemSdkLogger(): TangemSdkLogger - - fun getSettingsRepository(): SettingsRepository - - fun getBlockchainSDKFactory(): BlockchainSDKFactory - - fun getSendFeedbackEmailUseCase(): SendFeedbackEmailUseCase - - fun getWalletMetaInfoUseCase(): GetWalletMetaInfoUseCase - - fun getUrlOpener(): UrlOpener - - fun getShareManager(): ShareManager - - fun getAppRouter(): AppRouter - - fun getTangemAppLogger(): TangemAppLoggerInitializer - - fun getTransactionSignerFactory(): TransactionSignerFactory - - fun getOnboardingV2FeatureToggles(): OnboardingV2FeatureToggles - - fun getOnboardingRepository(): OnboardingRepository - - fun getExcludedBlockchains(): ExcludedBlockchains - - fun getAppLogsStore(): AppLogsStore - - fun getClipboardManager(): ClipboardManager - - fun getSettingsManager(): SettingsManager + fun getTangemLoggingInitializer(): TangemLoggingInitializer fun getBlockchainExceptionHandler(): BlockchainExceptionHandler - @GlobalUiMessageSender - fun getUiMessageSender(): UiMessageSender - fun getWorkerFactory(): HiltWorkerFactory - fun getColdUserWalletBuilderFactory(): ColdUserWalletBuilder.Factory - fun getApiConfigsManager(): ApiConfigsManager - fun getUserWalletsListRepository(): UserWalletsListRepository - - fun getTangemHotSdk(): TangemHotSdk - fun getWcInitializeUseCase(): WcInitializeUseCase - fun getTrackingContextProxy(): TrackingContextProxy - fun getABTestsManager(): ABTestsManager fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory - fun getCustomerIoFeatureToggles(): CustomerIoFeatureToggles - - fun getScanFailsRequester(): ScanFailsRequester + fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt index 0798bcf8cf..63dcc8eeee 100644 --- a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt +++ b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt @@ -7,12 +7,12 @@ import androidx.lifecycle.LifecycleOwner import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase import com.tangem.tap.LockTimerWorker.Companion.TAG -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -30,6 +30,7 @@ internal class LockUserWalletsTimer( private val coroutineScope: CoroutineScope, private val clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase, private val passwordRequester: HotWalletPasswordRequester, + private val appRouter: AppRouter, ) : LifecycleOwner by context as LifecycleOwner, DefaultLifecycleObserver { @@ -57,7 +58,7 @@ internal class LockUserWalletsTimer( if (shouldOpenWelcomeScreenOnResume) { passwordRequester.dismiss() clearAllHotWalletContextualUnlockUseCase.invoke() - store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } + appRouter.replaceAll(AppRoute.Welcome()) settingsRepository.setShouldOpenWelcomeScreenOnResume(value = false) } } @@ -121,7 +122,7 @@ internal class LockUserWalletsTimer( .onRight { passwordRequester.dismiss() clearAllHotWalletContextualUnlockUseCase.invoke() - store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } + appRouter.replaceAll(AppRoute.Welcome()) } } } diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index d24ee9d8cf..3349a0933d 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -19,6 +19,8 @@ import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.net.toUri @@ -26,6 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import arrow.core.getOrElse +import com.tangem.common.routing.AppRouter import com.tangem.common.routing.deeplink.DeeplinkConst.WEBLINK_KEY import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -37,7 +40,6 @@ import com.tangem.core.ui.extensions.UserInteractionTracker import com.tangem.data.balancehiding.DefaultDeviceFlipDetector import com.tangem.data.card.sdk.CardSdkOwner import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -58,7 +60,6 @@ import com.tangem.tap.common.analytics.events.Push import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler import com.tangem.tap.features.main.MainViewModel -import com.tangem.tap.proxy.redux.DaggerGraphAction import com.tangem.tap.routing.component.RoutingComponent import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.tap.routing.utils.DeepLinkFactory @@ -101,9 +102,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject lateinit var injectedTangemSdkManager: TangemSdkManager - @Inject - lateinit var scanCardUseCase: ScanCardUseCase - @Inject lateinit var settingsRepository: SettingsRepository @@ -120,6 +118,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var appRouterConfig: AppRouterConfig + @Inject + internal lateinit var appRouter: AppRouter + @Inject internal lateinit var routingComponentFactory: RoutingComponent.Factory @@ -240,7 +241,11 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { setContent { CompositionLocalProvider(LocalUserInteractionTracker provides userInteractionTracker) { - routingComponent.Content(Modifier.fillMaxSize()) + routingComponent.Content( + Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) } } } @@ -259,13 +264,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { userWalletsListRepository = userWalletsListRepository, clearAllHotWalletContextualUnlockUseCase = clearAllHotWalletContextualUnlockUseCase, passwordRequester = passwordRequester, - ) - - store.dispatch( - DaggerGraphAction.SetActivityDependencies( - scanCardUseCase = scanCardUseCase, - cardSdkConfigRepository = cardSdkConfigRepository, - ), + appRouter = appRouter, ) } diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index e03df1316b..3684c3af20 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -8,59 +8,20 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import coil.ImageLoader import coil.ImageLoaderFactory -import com.chuckerteam.chucker.api.ChuckerInterceptor -import com.tangem.Log -import com.tangem.TangemSdkLogger import com.tangem.blockchain.common.ExceptionHandler -import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder -import com.tangem.blockchainsdk.BlockchainSDKFactory -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.common.routing.AppRouter import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.Analytics -import com.tangem.core.analytics.api.ParamsInterceptor import com.tangem.core.analytics.filter.AppsFlyerEventFilter import com.tangem.core.analytics.filter.OneTimeEventFilter -import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic -import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.data.card.TransactionSignerFactory import com.tangem.datasource.api.common.MoshiConverter import com.tangem.datasource.api.common.config.managers.ApiConfigsManager -import com.tangem.datasource.api.common.createNetworkLoggingInterceptor -import com.tangem.datasource.connection.NetworkConnectionManager import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.datasource.local.config.issuers.IssuersConfigStorage -import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.utils.NetworkLogsSaveInterceptor -import com.tangem.datasource.utils.WireMockRedirectInterceptor -import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.GetAppThemeModeUseCase -import com.tangem.domain.apptheme.repository.AppThemeModeRepository -import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository -import com.tangem.domain.card.ScanCardProcessor -import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.common.LogConfig -import com.tangem.domain.feedback.GetWalletMetaInfoUseCase -import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.repository.OnboardingRepository -import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles -import com.tangem.operations.attestation.api.TangemApiServiceSettings import com.tangem.tap.common.analytics.AnalyticsFactory -import com.tangem.tap.common.analytics.CustomerIoFeatureToggles import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler @@ -69,21 +30,15 @@ import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient import com.tangem.tap.common.analytics.handlers.customerio.CustomerIoAnalyticsHandler import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler import com.tangem.tap.common.images.createCoilImageLoader -import com.tangem.tap.common.log.TangemAppLoggerInitializer -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.common.redux.appReducer -import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.proxy.AppStateHolder -import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.common.log.TangemLoggingInitializer import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch -import org.rekotlin.Store -lateinit var store: Store +lateinit var walletsRepository: WalletsRepository val foregroundActivityObserver = ForegroundActivityObserver @@ -93,12 +48,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val entryPoint: ApplicationEntryPoint get() = EntryPoints.get(this, ApplicationEntryPoint::class.java) - private val appStateHolder: AppStateHolder - get() = entryPoint.getAppStateHolder() - - private val issuersConfigStorage: IssuersConfigStorage - get() = entryPoint.getIssuersConfigStorage() - private val environmentConfig: EnvironmentConfig get() = entryPoint.getEnvironmentConfig() @@ -108,98 +57,14 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val excludedBlockchainsManager: ExcludedBlockchainsManager get() = entryPoint.getExcludedBlockchainsManager() - private val networkConnectionManager: NetworkConnectionManager - get() = entryPoint.getNetworkConnectionManager() - - private val cardScanningFeatureToggles: CardScanningFeatureToggles - get() = entryPoint.getCardScanningFeatureToggles() - - private val scanCardProcessor: ScanCardProcessor - get() = entryPoint.getScanCardProcessor() - - private val appCurrencyRepository: AppCurrencyRepository - get() = entryPoint.getAppCurrencyRepository() - - private val walletManagersFacade: WalletManagersFacade - get() = entryPoint.getWalletManagersFacade() - - private val appThemeModeRepository: AppThemeModeRepository - get() = entryPoint.getAppThemeModeRepository() - - private val balanceHidingRepository: BalanceHidingRepository - get() = entryPoint.getBalanceHidingRepository() - - private val appPreferencesStore: AppPreferencesStore - get() = entryPoint.getAppPreferencesStore() - val getAppThemeModeUseCase: GetAppThemeModeUseCase get() = entryPoint.getGetAppThemeModeUseCase() - private val walletsRepository: WalletsRepository - get() = entryPoint.getWalletsRepository() - private val oneTimeEventFilter: OneTimeEventFilter get() = entryPoint.getOneTimeEventFilter() - private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase - get() = entryPoint.getWasTwinsOnboardingShownUseCase() - - private val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase - get() = entryPoint.getSaveTwinsOnboardingShownUseCase() - - private val cardRepository: CardRepository - get() = entryPoint.getCardRepository() - - private val tangemSdkLogger: TangemSdkLogger - get() = entryPoint.getTangemSdkLogger() - - private val settingsRepository: SettingsRepository - get() = entryPoint.getSettingsRepository() - - private val blockchainSDKFactory: BlockchainSDKFactory - get() = entryPoint.getBlockchainSDKFactory() - - private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase - get() = entryPoint.getSendFeedbackEmailUseCase() - - private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase - get() = entryPoint.getWalletMetaInfoUseCase() - - private val urlOpener - get() = entryPoint.getUrlOpener() - - private val shareManager - get() = entryPoint.getShareManager() - - private val appRouter: AppRouter - get() = entryPoint.getAppRouter() - - private val tangemAppLoggerInitializer: TangemAppLoggerInitializer - get() = entryPoint.getTangemAppLogger() - - private val transactionSignerFactory: TransactionSignerFactory - get() = entryPoint.getTransactionSignerFactory() - - private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles - get() = entryPoint.getOnboardingV2FeatureToggles() - - private val onboardingRepository: OnboardingRepository - get() = entryPoint.getOnboardingRepository() - - private val excludedBlockchains: ExcludedBlockchains - get() = entryPoint.getExcludedBlockchains() - - private val appLogsStore: AppLogsStore - get() = entryPoint.getAppLogsStore() - - private val clipboardManager: ClipboardManager - get() = entryPoint.getClipboardManager() - - private val settingsManager: SettingsManager - get() = entryPoint.getSettingsManager() - - private val uiMessageSender: UiMessageSender - get() = entryPoint.getUiMessageSender() + private val tangemLoggingInitializer: TangemLoggingInitializer + get() = entryPoint.getTangemLoggingInitializer() private val blockchainExceptionHandler: BlockchainExceptionHandler get() = entryPoint.getBlockchainExceptionHandler() @@ -212,35 +77,20 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. .setWorkerFactory(workerFactory) .build() - private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory - get() = entryPoint.getColdUserWalletBuilderFactory() - private val apiConfigsManager: ApiConfigsManager get() = entryPoint.getApiConfigsManager() - private val userWalletsListRepository - get() = entryPoint.getUserWalletsListRepository() - - private val tangemHotSdk - get() = entryPoint.getTangemHotSdk() - private val wcInitializeUseCase get() = entryPoint.getWcInitializeUseCase() - private val trackingContextProxy - get() = entryPoint.getTrackingContextProxy() - private val abTestsManager: ABTestsManager get() = entryPoint.getABTestsManager() private val appsFlyerClientFactory: AppsFlyerClient.Factory get() = entryPoint.getAppsFlyerClientFactory() - private val customerIoFeatureToggles: CustomerIoFeatureToggles - get() = entryPoint.getCustomerIoFeatureToggles() - - private val scanFailsRequester - get() = entryPoint.getScanFailsRequester() + private val sendTransactionSignerInfoInterceptor + get() = entryPoint.getSendTransactionSignerInfoInterceptor() // endregion @@ -277,14 +127,14 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. * Initialize components that need to be initialized before [super.onCreate] is called */ fun preInit() { - tangemAppLoggerInitializer.initialize() + tangemLoggingInitializer.initAppLogging() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) } fun init() { - apiConfigsManager.initialize() + walletsRepository = entryPoint.getWalletsRepository() - store = createReduxStore() + apiConfigsManager.initialize() TangemLogger.i("APP STARTED") if (BuildConfig.TESTER_MENU_ENABLED) { @@ -292,107 +142,25 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. TangemLogger.i(excludedBlockchainsManager.toString()) } - initWithConfigDependency(environmentConfig = environmentConfig) + initAnalytics(application = this, environmentConfig = environmentConfig) abTestsManager.init() appScope.launch { launch(Dispatchers.IO) { loadNativeLibraries() - updateLogFiles() } } ExceptionHandler.append(blockchainExceptionHandler) - if (LogConfig.network.isBlockchainSdkNetworkLogEnabled) { - BlockchainSdkRetrofitBuilder.interceptors = buildList { - if (BuildConfig.MOCK_DATA_SOURCE) { - add(WireMockRedirectInterceptor()) - } - add(createNetworkLoggingInterceptor()) - add(ChuckerInterceptor(this@TangemApplication)) - } - - TangemApiServiceSettings.addInterceptors( - *buildList { - if (BuildConfig.MOCK_DATA_SOURCE) { - add(WireMockRedirectInterceptor()) - } - add(createNetworkLoggingInterceptor()) - add(ChuckerInterceptor(this@TangemApplication)) - add(NetworkLogsSaveInterceptor(appLogsStore)) - }.toTypedArray(), - ) - } - - appStateHolder.mainStore = store + tangemLoggingInitializer.initSdkLogging(this) wcInitializeUseCase.init( projectId = environmentConfig.walletConnectProjectId, ) } - private fun createReduxStore(): Store { - return Store( - reducer = { action, state -> appReducer(action, requireNotNull(state)) }, - middleware = AppState.getMiddleware(), - state = AppState( - daggerGraphState = DaggerGraphState( - networkConnectionManager = networkConnectionManager, - cardScanningFeatureToggles = cardScanningFeatureToggles, - scanCardProcessor = scanCardProcessor, - appCurrencyRepository = appCurrencyRepository, - walletManagersFacade = walletManagersFacade, - appStateHolder = appStateHolder, - appThemeModeRepository = appThemeModeRepository, - balanceHidingRepository = balanceHidingRepository, - walletsRepository = walletsRepository, - wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, - saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, - cardRepository = cardRepository, - settingsRepository = settingsRepository, - blockchainSDKFactory = blockchainSDKFactory, - sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, - getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, - issuersConfigStorage = issuersConfigStorage, - urlOpener = urlOpener, - shareManager = shareManager, - appRouter = appRouter, - transactionSignerFactory = transactionSignerFactory, - onboardingV2FeatureToggles = onboardingV2FeatureToggles, - onboardingRepository = onboardingRepository, - excludedBlockchains = excludedBlockchains, - appPreferencesStore = appPreferencesStore, - clipboardManager = clipboardManager, - settingsManager = settingsManager, - uiMessageSender = uiMessageSender, - coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, - userWalletsListRepository = userWalletsListRepository, - tangemHotSdk = tangemHotSdk, - trackingContextProxy = trackingContextProxy, - scanFailsRequester = scanFailsRequester, - ), - ), - ) - } - - private fun updateLogFiles() { - appLogsStore.deleteOldLogsFile() - - if (!BuildConfig.TESTER_MENU_ENABLED) { - appLogsStore.deleteLastLogFile() - } - - // Temporarily logs are not saved - // scope.launch { - // if (!appPreferencesStore.getSyncOrDefault(WAS_LOG_FILE_CLEARED, false)) { - // appLogsStore.deleteLastLogFile() - // appPreferencesStore.store(WAS_LOG_FILE_CLEARED, true) - // } - // } - } - override fun newImageLoader(): ImageLoader { return createCoilImageLoader( context = this, @@ -404,20 +172,13 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. System.loadLibrary("TrustWalletCore") } - private fun initWithConfigDependency(environmentConfig: EnvironmentConfig) { - initAnalytics(this, environmentConfig) - Log.addLogger(logger = tangemSdkLogger) - } - private fun initAnalytics(application: Application, environmentConfig: EnvironmentConfig) { val factory = AnalyticsFactory() factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder()) factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder()) factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder(appsFlyerClientFactory)) - if (customerIoFeatureToggles.isFeatureEnabled) { - factory.addHandlerBuilder(CustomerIoAnalyticsHandler.Builder()) - } + factory.addHandlerBuilder(CustomerIoAnalyticsHandler.Builder()) factory.addFilter(oneTimeEventFilter) factory.addFilter(AppsFlyerEventFilter()) @@ -430,23 +191,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. jsonConverter = MoshiConverter.sdkMoshiConverter, ) - Analytics.addParamsInterceptor( - interceptor = object : ParamsInterceptor { - override fun id(): String = "SendTransactionSignerInfoInterceptor" - - override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is Basic.TransactionSent - - override fun intercept(params: MutableMap) { - val isLastSignWithRing = store.state.globalState.isLastSignWithRing - - params[AnalyticsParam.WALLET_FORM] = if (isLastSignWithRing) { - WalletForm.Ring.name - } else { - WalletForm.Card.name - } - } - }, - ) + Analytics.addParamsInterceptor(interceptor = sendTransactionSignerInfoInterceptor) factory.build(Analytics, buildData) } diff --git a/app/src/main/java/com/tangem/tap/common/TestActions.kt b/app/src/main/java/com/tangem/tap/common/TestActions.kt deleted file mode 100644 index 95edcd851e..0000000000 --- a/app/src/main/java/com/tangem/tap/common/TestActions.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.common - -/** -[REDACTED_AUTHOR] - */ - -object TestActions { - - // It used only for the test actions in debug or debug_beta builds - var isTestAmountInjectionForWalletManagerEnabled = false -} - -typealias TestAction = Pair Unit> \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/CustomerIoFeatureToggles.kt b/app/src/main/java/com/tangem/tap/common/analytics/CustomerIoFeatureToggles.kt deleted file mode 100644 index c847d03c9f..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/CustomerIoFeatureToggles.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.common.analytics - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import javax.inject.Inject - -class CustomerIoFeatureToggles @Inject constructor( - private val featureTogglesManager: FeatureTogglesManager, -) { - - val isFeatureEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.CUSTOMER_IO_ENABLED) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt index a9a25c5acc..72a24b1a69 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt @@ -3,7 +3,9 @@ package com.tangem.tap.common.analytics.handlers.amplitude import com.tangem.core.analytics.api.AnalyticsHandler import com.tangem.core.analytics.api.AnalyticsUserIdHandler import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.tap.common.analytics.AnalyticsEventsLogger import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder +import com.tangem.wallet.BuildConfig class AmplitudeAnalyticsHandler( private val client: AmplitudeAnalyticsClient, @@ -29,10 +31,20 @@ class AmplitudeAnalyticsHandler( class Builder : AnalyticsHandlerBuilder { override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler { return AmplitudeAnalyticsHandler( - client = if (data.logConfig.isAmplitudeLogEnabled) { - AmplitudeLogClient(data.jsonConverter) + client = if (BuildConfig.TESTER_MENU_ENABLED) { + AmplitudeClient( + application = data.application, + key = requireNotNull(data.config.amplitudeApiKeyDev) { + "Amplitude api key not found in ${BuildConfig.BUILD_TYPE}" + }, + logger = AnalyticsEventsLogger(name = ID, jsonConverter = data.jsonConverter), + ) } else { - AmplitudeClient(data.application, data.config.amplitudeApiKey) + AmplitudeClient( + application = data.application, + key = data.config.amplitudeApiKey, + logger = null, + ) }, ) } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt index a5ffec919c..26e69a96e6 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt @@ -5,7 +5,9 @@ import com.amplitude.api.Amplitude import com.amplitude.api.AmplitudeClient import com.tangem.core.analytics.api.EventLogger import com.tangem.core.analytics.api.UserIdHolder +import com.tangem.tap.common.analytics.AnalyticsEventsLogger import com.tangem.utils.converter.Converter +import com.tangem.wallet.BuildConfig import org.json.JSONObject /** @@ -16,6 +18,7 @@ interface AmplitudeAnalyticsClient : EventLogger, UserIdHolder internal class AmplitudeClient( application: Application, key: String, + private val logger: AnalyticsEventsLogger?, ) : AmplitudeAnalyticsClient { private val client: AmplitudeClient = Amplitude.getInstance() @@ -23,6 +26,7 @@ internal class AmplitudeClient( init { client.initialize(application, key) client.enableForegroundTracking(application) + client.enableLogging(BuildConfig.TESTER_MENU_ENABLED) } override fun setUserId(userId: String) { @@ -34,6 +38,7 @@ internal class AmplitudeClient( } override fun logEvent(event: String, params: Map) { + logger?.logEvent(event, params) client.logEvent(event, ParamsToJSONObjectConverter().convert(params)) } } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeLogClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeLogClient.kt deleted file mode 100644 index f24a5925a3..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeLogClient.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.tap.common.analytics.handlers.amplitude - -import com.tangem.common.json.MoshiJsonConverter -import com.tangem.tap.common.analytics.AnalyticsEventsLogger - -/** -[REDACTED_AUTHOR] - */ -internal class AmplitudeLogClient( - jsonConverter: MoshiJsonConverter, -) : AmplitudeAnalyticsClient { - - private val logger: AnalyticsEventsLogger = AnalyticsEventsLogger(AmplitudeAnalyticsHandler.ID, jsonConverter) - - private var userId: String? = null - - override fun setUserId(userId: String) { - this.userId = userId - } - - override fun clearUserId() { - this.userId = null - } - - override fun logEvent(event: String, params: Map) { - logger.logEvent(event, params) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt index 5b5902f3cc..24e95b0b93 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt @@ -10,10 +10,8 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store +import com.tangem.tap.walletsRepository import kotlinx.coroutines.runBlocking /** @@ -23,8 +21,6 @@ class CardContextInterceptor( private val scanResponse: ScanResponse, ) : ParamsInterceptor { - private val walletsRepository = store.inject(DaggerGraphState::walletsRepository) - private val userWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build() override fun id(): String = CardContextInterceptor.id() diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Context.kt b/app/src/main/java/com/tangem/tap/common/extensions/Context.kt deleted file mode 100644 index 14f4cd0dbf..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/Context.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.tap.common.extensions - -import android.content.* -import android.content.pm.* -import android.content.res.* -import android.net.* -import androidx.annotation.* -import androidx.core.content.* - -/** - * Get uri to any resource type via given Resource Instance - * @param resId - resource id - * @throws Resources.NotFoundException if the given ID does not exist. - * @return - Uri to resource by the given ID - */ -@Throws(Resources.NotFoundException::class) -fun Context.resourceUri(@AnyRes resId: Int): Uri { - return Uri.parse( - ContentResolver.SCHEME_ANDROID_RESOURCE + - "://" + resources.getResourcePackageName(resId) + - '/' + resources.getResourceTypeName(resId) + - '/' + resources.getResourceEntryName(resId), - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt b/app/src/main/java/com/tangem/tap/common/extensions/Store.kt deleted file mode 100644 index 8d38cb9f3a..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.tap.common.extensions - -import com.tangem.common.routing.AppRouter -import com.tangem.domain.common.extensions.withMainContext -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Store - -/** - * Dispatch action with creating the new coroutine with the Main dispatcher - * - * @see dispatchWithMain - */ -fun Store<*>.dispatchOnMain(action: Action) { - scope.launch(Dispatchers.Main) { - dispatch(action) - } -} - -/** - * Dispatch action on the Main coroutine context - * - * @param action [Action] to be dispatched - * - * @see dispatchOnMain - * */ -suspend fun Store<*>.dispatchWithMain(action: Action) { - withMainContext { - dispatch(action) - } -} - -suspend fun Store.onUserWalletSelected(userWallet: UserWallet) { - state.globalState.tapWalletManager.onWalletSelected(userWallet) -} - -/** - * Dispatch action inside a coroutine with the Main dispatcher - */ -@Deprecated( - message = "Use dispatchWithMain instead", - replaceWith = ReplaceWith(expression = "dispatchWithMain"), -) -suspend fun dispatchOnMain(vararg actions: Action) { - withMainContext { actions.forEach { store.dispatch(it) } } -} - -fun Store.dispatchNavigationAction(action: AppRouter.() -> Unit) { - inject(DaggerGraphState::appRouter).action() -} - -inline fun Store.inject(getDependency: DaggerGraphState.() -> T?): T { - return requireNotNull(state.daggerGraphState.getDependency()) { - "${T::class.simpleName.orEmpty()} isn't initialized " - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/UI.kt b/app/src/main/java/com/tangem/tap/common/extensions/UI.kt index 355da51669..bc653053c7 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/UI.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/UI.kt @@ -1,56 +1,11 @@ -@file:Suppress("TooManyFunctions") - package com.tangem.tap.common.extensions import android.content.Context -import android.graphics.drawable.Drawable -import android.view.View import androidx.annotation.ColorInt import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.core.content.ContextCompat -fun Context.getDrawableCompat(@DrawableRes drawableResId: Int): Drawable? { - return ContextCompat.getDrawable(this, drawableResId) -} - @ColorInt fun Context.getColorCompat(@ColorRes colorRes: Int): Int { return ContextCompat.getColor(this, colorRes) -} - -@ColorInt -fun View.getColor(@ColorRes colorRes: Int): Int { - return ContextCompat.getColor(context, colorRes) -} - -fun View.getString(@StringRes id: Int): String { - return context.getString(id) -} - -fun View.getString(@StringRes id: Int, vararg formatArgs: String): String { - return context.getString(id, *formatArgs) -} - -fun View.show(show: Boolean, invokeBeforeStateChanged: (() -> Unit)? = null) { - return if (show) this.show(invokeBeforeStateChanged) else this.hide(invokeBeforeStateChanged) -} - -fun View.show(invokeBeforeStateChanged: (() -> Unit)? = null) { - if (this.visibility == View.VISIBLE) return - - invokeBeforeStateChanged?.invoke() - this.visibility = View.VISIBLE -} - -fun View.hide(invokeBeforeStateChanged: (() -> Unit)? = null) { - if (this.visibility == View.GONE) return - - invokeBeforeStateChanged?.invoke() - this.visibility = View.GONE -} - -fun View.getString(resId: Int, vararg formatArgs: Any?): String { - return context.getString(resId, *formatArgs) } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt b/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt deleted file mode 100644 index 06dd58d367..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.tangem.tap.common.extensions - -import com.tangem.blockchain.common.BlockchainSdkError -import com.tangem.blockchain.common.Wallet -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchainsdk.utils.amountToCreateAccount -import com.tangem.common.services.Result -import com.tangem.tap.common.TestActions -import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.getFirstToken -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.delay - -/** -[REDACTED_AUTHOR] - */ -@Deprecated( - message = "Use WalletStoresManager.fetch({userWalletId}, refresh = true) (to update all user wallet tokens)" + - "or WalletCurrenciesManager.update(...) (to update only one user wallet blockchain and its tokens) instead", -) -@Suppress("MagicNumber") -suspend fun WalletManager.safeUpdate(isDemoCard: Boolean): Result = try { - if (isDemoCard || TestActions.isTestAmountInjectionForWalletManagerEnabled) { - delay(500) - TestActions.isTestAmountInjectionForWalletManagerEnabled = false - Result.Success(wallet) - } else { - update() - Result.Success(wallet) - } -} catch (exception: Exception) { - TangemLogger.e("Error", exception) - - val networkConnectionManager = store.inject(DaggerGraphState::networkConnectionManager) - if (!networkConnectionManager.isOnline) { - Result.Failure(TapError.NoInternetConnection()) - } else { - val blockchain = wallet.blockchain - val amountToCreateAccount = blockchain.amountToCreateAccount(this, wallet.getFirstToken()) - - if (exception is BlockchainSdkError.AccountNotFound && amountToCreateAccount != null) { - Result.Failure(TapError.WalletManager.NoAccountError(amountToCreateAccount.toString())) - } else { - when (exception) { - is BlockchainSdkError -> Result.Failure(exception) - else -> { - val message = exception.cause?.localizedMessage ?: "Unknown error" - Result.Failure(TapError.WalletManager.InternalError(message)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt b/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt index e31c519069..fced2e9ee9 100644 --- a/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt +++ b/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt @@ -3,13 +3,15 @@ package com.tangem.tap.common.libs.blockchainsdk import com.tangem.Message import com.tangem.TangemSdk import com.tangem.blockchain.common.TransactionSigner +import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm +import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.data.card.TransactionSignerFactory import com.tangem.domain.card.models.TwinKey -import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.TangemSigner -import com.tangem.tap.store -internal class DefaultTransactionSignerFactory : TransactionSignerFactory { +internal class DefaultTransactionSignerFactory( + private val lastSignedWalletFormStore: LastSignedWalletFormStore, +) : TransactionSignerFactory { override fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner { return TangemSigner( @@ -18,7 +20,9 @@ internal class DefaultTransactionSignerFactory : TransactionSignerFactory { initialMessage = Message(), twinKey = twinKey, ) { signResponse -> - store.dispatch(action = GlobalAction.IsSignWithRing(signResponse.isRing)) + lastSignedWalletFormStore.update( + if (signResponse.isRing) WalletForm.Ring else WalletForm.Card, + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt b/app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt new file mode 100644 index 0000000000..600f325928 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt @@ -0,0 +1,36 @@ +package com.tangem.tap.common.log + +import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.utils.logging.Severity +import com.tangem.utils.logging.TangemLogger + +/** + * [TangemLogger.LogWriter] that persists log entries to [AppLogsStore]. + * + * Only [Severity.Error] and [Severity.Info] are written. The `shouldSanitize` flag is forwarded to + * [AppLogsStore.saveLogMessage], so callers that deliberately log unsanitized content + * (`shouldSanitize = false`) bypass the sanitizer. + */ +internal class FileLogWriter( + private val appLogsStore: AppLogsStore, +) : TangemLogger.LogWriter { + + override fun isLoggable(severity: Severity, tag: String): Boolean { + return severity == Severity.Error || severity == Severity.Info + } + + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + appLogsStore.saveLogMessage( + tag = tag, + message = message, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/LogcatLogWriter.kt b/app/src/main/java/com/tangem/tap/common/log/LogcatLogWriter.kt new file mode 100644 index 0000000000..76ac68af8b --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/LogcatLogWriter.kt @@ -0,0 +1,86 @@ +package com.tangem.tap.common.log + +import android.os.Build +import android.util.Log +import com.tangem.utils.logging.Severity +import com.tangem.utils.logging.TangemLogger + +/** + * [TangemLogger.LogWriter] that pretty-prints log entries to Logcat. + * + * Wraps each entry in unicode borders and chunks long messages so that they fit + * Android's per-entry byte limit (~4076 bytes). + */ +internal class LogcatLogWriter : TangemLogger.LogWriter { + + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + val priority = severity.toAndroidPriority() + val truncatedTag = tag.truncateForLogcat() + val finalMessage = if (throwable != null) { + "$message\n${Log.getStackTraceString(throwable)}" + } else { + message + } + printBoxed(priority, truncatedTag, finalMessage) + } + + private fun printBoxed(priority: Int, tag: String, message: String) { + Log.println(priority, tag, TOP_BORDER) + val bytes = message.toByteArray() + val length = bytes.size + if (length <= CHUNK_SIZE) { + printContent(priority, tag, message) + } else { + var i = 0 + while (i < length) { + val count = (length - i).coerceAtMost(CHUNK_SIZE) + printContent(priority, tag, String(bytes, i, count)) + i += CHUNK_SIZE + } + } + Log.println(priority, tag, BOTTOM_BORDER) + } + + private fun printContent(priority: Int, tag: String, chunk: String) { + chunk.split(System.lineSeparator()).forEach { line -> + Log.println(priority, tag, "$HORIZONTAL_LINE $line") + } + } + + @Suppress("MagicNumber") + private fun String.truncateForLogcat(): String { + // Tag length limit was removed in API 26. + return if (length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { + this + } else { + substring(0, MAX_TAG_LENGTH) + } + } + + private fun Severity.toAndroidPriority(): Int = when (this) { + Severity.Verbose -> Log.VERBOSE + Severity.Debug -> Log.DEBUG + Severity.Info -> Log.INFO + Severity.Warn -> Log.WARN + Severity.Error -> Log.ERROR + Severity.Assert -> Log.ASSERT + } + + private companion object { + // Android's max per-entry byte limit is ~4076; leave headroom for borders. + const val CHUNK_SIZE = 4000 + + const val MAX_TAG_LENGTH = 23 + + const val HORIZONTAL_LINE = "│" + const val DIVIDER = "────────────────────────────────────────────────────────" + const val TOP_BORDER = "┌$DIVIDER$DIVIDER" + const val BOTTOM_BORDER = "└$DIVIDER$DIVIDER" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt deleted file mode 100644 index 0231c5fb23..0000000000 --- a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.tangem.tap.common.log - -import android.os.Build -import android.util.Log -import co.touchlab.kermit.BaseLogger -import co.touchlab.kermit.LogWriter -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import com.orhanobut.logger.AndroidLogAdapter -import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.utils.logging.TangemLogger -import com.tangem.wallet.BuildConfig -import java.util.regex.Pattern -import com.orhanobut.logger.Logger as PrettyLogger - -/** - * Tangem app logger - * - * @property appLogsStore app logs store - * -[REDACTED_AUTHOR] - */ -class TangemAppLoggerInitializer( - private val appLogsStore: AppLogsStore, -) { - - /** Initialize */ - fun initialize() { - if (IS_LOG_ENABLED) { - PrettyLogger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) - } - - Logger.setLogWriters(KermitLogWriter(::finalLogOutput)) - } - - private fun finalLogOutput(priority: Int, tag: String?, message: String, t: Throwable?) { - if (IS_LOG_ENABLED) { - PrettyLogger.log(priority, tag, message, t) - } - - if (PERMITTED_PRIORITY.contains(priority)) { - appLogsStore.saveLogMessage( - tag = tag ?: "TangemAppLogger", - message = message, - ) - } - } - - @Suppress("BooleanPropertyNaming") - private companion object { - val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED - val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO) - } -} - -private class KermitLogWriter( - private val finalLogOutput: (priority: Int, tag: String?, message: String, t: Throwable?) -> Unit, -) : LogWriter() { - - private val fqcnIgnore = setOf( - LogWriter::class.java.name, - KermitLogWriter::class.java.name, - BaseLogger::class.java.name, - Logger::class.java.name, - TangemLogger::class.java.name, - TangemLogger.TaggedLogger::class.java.name, - ) - - override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - val priority = when (severity) { - Severity.Verbose -> PrettyLogger.VERBOSE - Severity.Debug -> PrettyLogger.DEBUG - Severity.Info -> PrettyLogger.INFO - Severity.Warn -> PrettyLogger.WARN - Severity.Error -> PrettyLogger.ERROR - Severity.Assert -> PrettyLogger.ASSERT - } - - val finalTag = if (tag != KERMIT_LOGGER_DEFAULT_TAG) { - tag - } else { - /** - * like in [Logger.debugTree.tag] - */ - @Suppress("UnnecessaryLet", "ThrowingExceptionsWithoutMessageOrCause") - Throwable().stackTrace - .first { it.className !in fqcnIgnore } - .let(::createStackElementTag) - } - - finalLogOutput(priority, finalTag, message, throwable) - } - - /** - * copy from [Logger.debugTree.createStackElementTag] - */ - @Suppress("MagicNumber") - private fun createStackElementTag(element: StackTraceElement): String? { - var tag = element.className.substringAfterLast('.') - val m = ANONYMOUS_CLASS.matcher(tag) - if (m.find()) { - tag = m.replaceAll("") - } - // Tag length limit was removed in API 26. - return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { - tag - } else { - tag.substring(0, MAX_TAG_LENGTH) - } - } - - private companion object { - private const val KERMIT_LOGGER_DEFAULT_TAG = "" - - /** - * copy from [Logger.debugTree.Companion] - */ - private const val MAX_TAG_LENGTH = 23 - private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt b/app/src/main/java/com/tangem/tap/common/log/TangemBlockchainSDKLogger.kt similarity index 94% rename from app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt rename to app/src/main/java/com/tangem/tap/common/log/TangemBlockchainSDKLogger.kt index fdfcf8789e..c97dc1f59a 100644 --- a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemBlockchainSDKLogger.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.data +package com.tangem.tap.common.log import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.datasource.local.logs.AppLogsStore diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt index f938ef17cc..38de07528e 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt @@ -6,27 +6,40 @@ import com.tangem.TangemSdkLogger import com.tangem.datasource.local.logs.AppLogsStore /** - * CardSDK logger implementation + * CardSDK logger implementation. * - * @property levels logging levels - * @property messageFormatter message formatter - * @property appLogsStore app logs store + * @property appLogsStore app logs store * [REDACTED_AUTHOR] */ -@Suppress("UnusedPrivateMember") internal class TangemCardSDKLogger( - private val levels: List, - private val messageFormatter: LogFormat, private val appLogsStore: AppLogsStore, ) : TangemSdkLogger { + private val messageFormatter: LogFormat = LogFormat.StairsFormatter() + override fun log(message: () -> String, level: Log.Level) { - if (!levels.contains(level)) return + if (!LEVELS.contains(level)) return appLogsStore.saveLogMessage( tag = "CardSDK_${level.name}", message = messageFormatter.format(message = message, level = level), ) } + + private companion object { + val LEVELS = listOf( + Log.Level.ApduCommand, + Log.Level.Apdu, + Log.Level.Tlv, + Log.Level.Nfc, + Log.Level.Command, + Log.Level.Session, + Log.Level.View, + Log.Level.Network, + Log.Level.Error, + Log.Level.Biometric, + Log.Level.Info, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt new file mode 100644 index 0000000000..6e9e000506 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt @@ -0,0 +1,80 @@ +package com.tangem.tap.common.log + +import android.app.Application +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.tangem.Log +import com.tangem.TangemSdkLogger +import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder +import com.tangem.datasource.api.common.createNetworkLoggingInterceptor +import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.datasource.utils.NetworkLogsSaveInterceptor +import com.tangem.datasource.utils.WireMockRedirectInterceptor +import com.tangem.domain.common.LogConfig +import com.tangem.operations.attestation.api.TangemApiServiceSettings +import com.tangem.utils.logging.TangemLogger +import com.tangem.wallet.BuildConfig + +/** + * Owns all app-startup wiring of the logging subsystem in a single place: + * - [initAppLogging] — registers [TangemLogger] writers (Logcat + file). + * - [initSdkLogging] — registers the Card SDK logger with [Log] and installs OkHttp + * interceptors for the Blockchain SDK and the Tangem API. + * + * @property appLogsStore app logs store used by file-based writer and the network logs save + * interceptor + * @property tangemSdkLogger Card SDK logger registered with [Log.addLogger] + * +[REDACTED_AUTHOR] + */ +class TangemLoggingInitializer( + private val appLogsStore: AppLogsStore, + private val tangemSdkLogger: TangemSdkLogger, +) { + + fun initAppLogging() { + TangemLogger.setLogWriters( + buildList { + if (BuildConfig.LOG_ENABLED) { + add(LogcatLogWriter()) + } + add(FileLogWriter(appLogsStore)) + }, + ) + } + + /** + * Configure logging for the underlying SDKs: + * - register [tangemSdkLogger] with the Card SDK static [Log] facade, + * - install OkHttp interceptors for the Blockchain SDK and Tangem API. + * + * Must be called from `TangemApplication.init()` AFTER `entryPoint.getWalletsRepository()` + * has triggered Hilt singletons construction — in particular `DefaultCardSdkProvider`, + * whose init block registers `AddHeadersInterceptor` in [TangemApiServiceSettings]. + * Calling this method earlier would invert the OkHttp interceptor chain order and + * cause logging interceptors to see requests *without* auth headers. + */ + fun initSdkLogging(application: Application) { + Log.addLogger(logger = tangemSdkLogger) + + if (!LogConfig.network.isBlockchainSdkNetworkLogEnabled) return + + BlockchainSdkRetrofitBuilder.interceptors = buildList { + if (BuildConfig.MOCK_DATA_SOURCE) { + add(WireMockRedirectInterceptor()) + } + add(createNetworkLoggingInterceptor()) + add(ChuckerInterceptor(application)) + } + + TangemApiServiceSettings.addInterceptors( + *buildList { + if (BuildConfig.MOCK_DATA_SOURCE) { + add(WireMockRedirectInterceptor()) + } + add(createNetworkLoggingInterceptor()) + add(ChuckerInterceptor(application)) + add(NetworkLogsSaveInterceptor(appLogsStore)) + }.toTypedArray(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TimberFormatStrategy.kt b/app/src/main/java/com/tangem/tap/common/log/TimberFormatStrategy.kt deleted file mode 100644 index 0a43f525d4..0000000000 --- a/app/src/main/java/com/tangem/tap/common/log/TimberFormatStrategy.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.tangem.tap.common.log - -import com.orhanobut.logger.FormatStrategy -import com.orhanobut.logger.LogStrategy -import com.orhanobut.logger.LogcatLogStrategy - -class TimberFormatStrategy : FormatStrategy { - - private val logStrategy: LogStrategy = LogcatLogStrategy() - - override fun log(priority: Int, tag: String?, message: String) { - logTopBorder(priority, tag) - val bytes = message.toByteArray() - val length = bytes.size - if (length <= CHUNK_SIZE) { - logContent(priority, tag, message) - logBottomBorder(priority, tag) - return - } - var i = 0 - while (i < length) { - val count = (length - i).coerceAtMost(CHUNK_SIZE) - // create a new String with system's default charset (which is UTF-8 for Android) - logContent(priority, tag, String(bytes, i, count)) - i += CHUNK_SIZE - } - logBottomBorder(priority, tag) - } - - private fun logTopBorder(logType: Int, tag: String?) { - logChunk(logType, tag, TOP_BORDER) - } - - private fun logBottomBorder(logType: Int, tag: String?) { - logChunk(logType, tag, BOTTOM_BORDER) - } - - private fun logContent(logType: Int, tag: String?, chunk: String) { - chunk.split(System.lineSeparator()).forEach { line -> - logChunk(logType, tag, "$HORIZONTAL_LINE $line") - } - } - - private fun logChunk(priority: Int, tag: String?, chunk: String) { - logStrategy.log(priority, tag, chunk) - } - - private companion object { - /** - * Android's max limit for a log entry is ~4076 bytes, - * so 4000 bytes is used as chunk size since default charset - * is UTF-8 - */ - private const val CHUNK_SIZE = 4000 - - const val TOP_LEFT_CORNER = "┌" - const val BOTTOM_LEFT_CORNER = "└" - const val HORIZONTAL_LINE = "│" - const val DOUBLE_DIVIDER = "────────────────────────────────────────────────────────" - const val TOP_BORDER = TOP_LEFT_CORNER + DOUBLE_DIVIDER + DOUBLE_DIVIDER - const val BOTTOM_BORDER = BOTTOM_LEFT_CORNER + DOUBLE_DIVIDER + DOUBLE_DIVIDER - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt index ba6b3ddcfe..aef6c2946b 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt @@ -3,19 +3,12 @@ package com.tangem.tap.common.pushes import android.annotation.SuppressLint import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import com.tangem.tap.common.analytics.CustomerIoFeatureToggles import com.tangem.utils.logging.TangemLogger -import dagger.hilt.android.AndroidEntryPoint import io.customer.messagingpush.CustomerIOFirebaseMessagingService -import javax.inject.Inject -@AndroidEntryPoint @SuppressLint("MissingFirebaseInstanceTokenRefresh") internal class TangemPushNotificationService : FirebaseMessagingService() { - @Inject - lateinit var customerIoFeatureToggles: CustomerIoFeatureToggles - private val pushNotificationDelegate: PushNotificationDelegate by lazy { PushNotificationDelegate(applicationContext) } @@ -24,21 +17,17 @@ internal class TangemPushNotificationService : FirebaseMessagingService() { super.onNewToken(token) TangemLogger.d("New FCM token received: $token") - if (customerIoFeatureToggles.isFeatureEnabled) { - CustomerIOFirebaseMessagingService.onNewToken(applicationContext, token) - } + CustomerIOFirebaseMessagingService.onNewToken(applicationContext, token) } override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) - if (customerIoFeatureToggles.isFeatureEnabled) { - CustomerIOFirebaseMessagingService.onMessageReceived( - context = applicationContext, - remoteMessage = message, - handleNotificationTrigger = false, - ) - } + CustomerIOFirebaseMessagingService.onMessageReceived( + context = applicationContext, + remoteMessage = message, + handleNotificationTrigger = false, + ) val notification = message.notification ?: return val channelId = notification.channelId ?: TANGEM_CHANNEL_ID diff --git a/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt deleted file mode 100644 index 57be8c6e21..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.mainScope -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import kotlinx.coroutines.launch -import org.rekotlin.Middleware - -class AccessCodeRequestPolicyMiddleware { - val middleware: Middleware = { _, _ -> - { next -> - { action -> - if (action is GlobalAction.SaveScanResponse) { - updateAccessCodeRequestPolicy(action.scanResponse) - } - next(action) - } - } - } - - private fun updateAccessCodeRequestPolicy(scanResponse: ScanResponse) { - mainScope.launch { - val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes() - - store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( - isBiometricsRequestPolicy = shouldSaveAccessCodes && scanResponse.card.isAccessCodeSet, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt deleted file mode 100644 index bc16aae955..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.tap.common.redux.global.globalReducer -import com.tangem.tap.features.details.redux.DetailsReducer -import com.tangem.tap.proxy.redux.DaggerGraphReducer -import org.rekotlin.Action - -fun appReducer(action: Action, state: AppState): AppState { - if (action is AppAction.RestoreState) return action.state - - return AppState( - globalState = globalReducer(action, state), - detailsState = DetailsReducer.reduce(action, state), - daggerGraphState = DaggerGraphReducer.reduce(action, state), - ) -} - -sealed class AppAction : Action { - data class RestoreState(val state: AppState) : AppAction() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt deleted file mode 100644 index 89851458ac..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.tap.common.redux.global.GlobalMiddleware -import com.tangem.tap.common.redux.global.GlobalState -import com.tangem.tap.common.redux.legacy.LegacyMiddleware -import com.tangem.tap.features.details.redux.DetailsMiddleware -import com.tangem.tap.features.details.redux.DetailsState -import com.tangem.tap.proxy.redux.DaggerGraphMiddleware -import com.tangem.tap.proxy.redux.DaggerGraphState -import org.rekotlin.Middleware -import org.rekotlin.StateType - -data class AppState( - val globalState: GlobalState = GlobalState(), - val detailsState: DetailsState = DetailsState(), - val daggerGraphState: DaggerGraphState = DaggerGraphState(), -) : StateType { - - companion object { - fun getMiddleware(): List> { - return listOf( - logMiddleware, - GlobalMiddleware.handler, - DetailsMiddleware().detailsMiddleware, - LockUserWalletsTimerMiddleware().middleware, - AccessCodeRequestPolicyMiddleware().middleware, - DaggerGraphMiddleware.daggerGraphMiddleware, - LegacyMiddleware.legacyMiddleware, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/LockUserWalletsTimerMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LockUserWalletsTimerMiddleware.kt deleted file mode 100644 index 67bb53a5b5..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/LockUserWalletsTimerMiddleware.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.tap.lockUserWalletsTimer -import org.rekotlin.Middleware - -class LockUserWalletsTimerMiddleware { - val middleware: Middleware = { _, _ -> - { nextDispatch -> - { action -> - lockUserWalletsTimer?.restart() - nextDispatch(action) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt deleted file mode 100644 index 7dd3992c54..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.utils.logging.TangemLogger -import org.rekotlin.Middleware - -/** -[REDACTED_AUTHOR] - */ -val logMiddleware: Middleware = { _, _ -> - { nextDispatch -> - { action -> - TangemLogger.i("Dispatch action: ${action::class.java.simpleName}") - nextDispatch(action) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt deleted file mode 100644 index a36bd807b6..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.scan.ScanResponse -import org.rekotlin.Action - -sealed class GlobalAction : Action { - - data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction() - - data class ChangeAppCurrency(val appCurrency: AppCurrency) : GlobalAction() - object RestoreAppCurrency : GlobalAction() { - data class Success(val appCurrency: AppCurrency) : GlobalAction() - } - - data class IsSignWithRing(val isSignWithRing: Boolean) : GlobalAction() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt deleted file mode 100644 index f2df3df6e9..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware - -object GlobalMiddleware { - val handler = globalMiddlewareHandler -} - -private val globalMiddlewareHandler: Middleware = { _, _ -> - { nextDispatch -> - { action -> - handleAction(action) - nextDispatch(action) - } - } -} - -private fun handleAction(action: Action) { - when (action) { - is GlobalAction.RestoreAppCurrency -> restoreAppCurrency() - } -} - -private fun restoreAppCurrency() { - scope.launch { - val currency = store.inject(DaggerGraphState::appCurrencyRepository) - .getSelectedAppCurrency() - .firstOrNull() - ?: AppCurrency.Default - - store.dispatchWithMain(GlobalAction.RestoreAppCurrency.Success(currency)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt deleted file mode 100644 index e46630172e..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Action - -@Suppress("LongMethod", "ComplexMethod") -fun globalReducer(action: Action, state: AppState): GlobalState { - if (action !is GlobalAction) return state.globalState - - val globalState = state.globalState - - return when (action) { - is GlobalAction.SaveScanResponse -> { - globalState.copy(scanResponse = action.scanResponse) - } - is GlobalAction.ChangeAppCurrency -> { - globalState.copy(appCurrency = action.appCurrency) - } - is GlobalAction.RestoreAppCurrency.Success -> { - globalState.copy(appCurrency = action.appCurrency) - } - is GlobalAction.IsSignWithRing -> globalState.copy(isLastSignWithRing = action.isSignWithRing) - else -> globalState - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt deleted file mode 100644 index 01782e2e9a..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.domain.TapWalletManager -import org.rekotlin.StateType - -data class GlobalState( - @Deprecated("Use scan response from selected user wallet") - val scanResponse: ScanResponse? = null, - val tapWalletManager: TapWalletManager = TapWalletManager(), - val appCurrency: AppCurrency = AppCurrency.Default, - val isLastSignWithRing: Boolean = false, -) : StateType - -typealias CryptoCurrencyName = String \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt deleted file mode 100644 index aee323386c..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.tangem.tap.common.redux.legacy - -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.redux.LegacyAction -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.details.redux.AppSettingsState -import com.tangem.tap.features.details.redux.DetailsAction -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import org.rekotlin.Middleware - -@Suppress("MemberNameEqualsClassName") -internal object LegacyMiddleware { - private val prepareDetailsScreenJobHolder = JobHolder() - - val legacyMiddleware: Middleware = { _, _ -> - { next -> - { action -> - when (action) { - is LegacyAction.PrepareDetailsScreen -> { - selectedUserWallet() - .distinctUntilChanged { old, new -> - if (old is UserWallet.Cold && new is UserWallet.Cold) { - old.walletId == new.walletId && - old.scanResponse == new.scanResponse - } else { - old.walletId == new.walletId - } - } - .onEach { selectedUserWallet -> - val initializedAppSettingsStateContent = initializeAppSettingsState() - store.dispatchWithMain( - DetailsAction.PrepareScreen( - scanResponse = (selectedUserWallet as? UserWallet.Cold)?.scanResponse, - initializedAppSettingsState = initializedAppSettingsStateContent, - ), - ) - } - .flowOn(Dispatchers.IO) - .launchIn(scope) - .saveIn(prepareDetailsScreenJobHolder) - } - } - next(action) - } - } - } - - private fun selectedUserWallet(): Flow { - return store.inject(DaggerGraphState::userWalletsListRepository).selectedUserWallet.filterNotNull() - } - - /** - * LEGACY: We need to initialize [AppSettingsState] async to avoid drawing blocking - * previously it was initialized in runBlocking and blocked details screen - */ - private suspend fun initializeAppSettingsState(): AppSettingsState { - return AppSettingsState( - selectedAppCurrency = store.state.globalState.appCurrency, - selectedThemeMode = store.inject(DaggerGraphState::appThemeModeRepository).getAppThemeMode().firstOrNull() - ?: AppThemeMode.DEFAULT, - requireAccessCode = store.inject(DaggerGraphState::walletsRepository).requireAccessCode(), - useBiometricAuthentication = store.inject(DaggerGraphState::walletsRepository).useBiometricAuthentication(), - isHidingEnabled = store.inject(DaggerGraphState::balanceHidingRepository) - .getBalanceHidingSettings().isHidingEnabledInSettings, - needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, - hasSecuredWallets = store.inject(DaggerGraphState::userWalletsListRepository).hasSecuredWallets(), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt b/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt index e606ddfde2..586122f7b3 100644 --- a/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt +++ b/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt @@ -18,8 +18,9 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { val isPatched by lazy { hasSecurityPatch() } val isVulnerable = isAffected && !isPatched TangemLogger.i( - "CVE-2026-20435 check: isAffectedMediaTek=$isAffected, " + + messageString = "CVE-2026-20435 check: isAffectedMediaTek=$isAffected, " + "isPatched=$isPatched, isVulnerable=$isVulnerable", + shouldSanitize = false, ) isVulnerable } @@ -27,7 +28,10 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { private fun isAffectedMediaTekDevice(): Boolean { val socModel = resolveMediaTekSocModel() val isAffected = socModel != null && socModel in AFFECTED_MEDIATEK_SOCS - TangemLogger.i("CVE-2026-20435 SoC result: model=$socModel, isAffected=$isAffected") + TangemLogger.i( + messageString = "CVE-2026-20435 SoC result: model=$socModel, isAffected=$isAffected", + shouldSanitize = false, + ) return isAffected } @@ -36,7 +40,10 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val manufacturer = Build.SOC_MANUFACTURER val model = Build.SOC_MODEL - TangemLogger.i("CVE-2026-20435 Layer 1: SOC_MANUFACTURER=$manufacturer, SOC_MODEL=$model") + TangemLogger.i( + messageString = "CVE-2026-20435 Layer 1: SOC_MANUFACTURER=$manufacturer, SOC_MODEL=$model", + shouldSanitize = false, + ) if (manufacturer.equals("MediaTek", ignoreCase = true)) { extractSocModel(model)?.let { return it } } @@ -44,7 +51,7 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { // Layer 2: Build.HARDWARE often contains "mtXXXX" on MediaTek devices (public API) val hardware = Build.HARDWARE - TangemLogger.i("CVE-2026-20435 Layer 2: HARDWARE=$hardware") + TangemLogger.i(messageString = "CVE-2026-20435 Layer 2: HARDWARE=$hardware", shouldSanitize = false) extractSocModel(hardware)?.let { return it } return null diff --git a/app/src/main/java/com/tangem/tap/core/ui/DefaultHoldToConfirmButtonFeatureToggles.kt b/app/src/main/java/com/tangem/tap/core/ui/DefaultHoldToConfirmButtonFeatureToggles.kt deleted file mode 100644 index 1508daf99e..0000000000 --- a/app/src/main/java/com/tangem/tap/core/ui/DefaultHoldToConfirmButtonFeatureToggles.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.core.ui - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles -import javax.inject.Inject - -class DefaultHoldToConfirmButtonFeatureToggles @Inject constructor( - private val featureTogglesManager: FeatureTogglesManager, -) : HoldToConfirmButtonFeatureToggles { - override val isHoldToConfirmEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.HOLD_TO_CONFIRM_BUTTON_ENABLED) -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt b/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt similarity index 57% rename from core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt rename to app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt index d33089ff1b..69194e58e1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt @@ -1,26 +1,27 @@ -package com.tangem.datasource.info +package com.tangem.tap.data import android.os.Build import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider -import java.util.* +import com.tangem.wallet.BuildConfig +import java.util.Locale +import java.util.TimeZone import javax.inject.Inject -internal class AndroidAppInfoProvider @Inject constructor( - private val appVersionProvider: AppVersionProvider, -) : AppInfoProvider { +internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider { override val platform: String get() = "Android" override val device: String get() = "${Build.MANUFACTURER} ${Build.MODEL}" override val osVersion: String get() = Build.VERSION.RELEASE + override val sdkVersion: Int + get() = Build.VERSION.SDK_INT override val language: String - get() = Locale.getDefault().language + get() = Locale.getDefault().toLanguageTag() override val timezone: String get() = TimeZone.getDefault().id - override val appVersion: String - get() = appVersionProvider.versionName + override val appVersion: String = BuildConfig.VERSION_NAME + override val appVersionCode: Int = BuildConfig.VERSION_CODE override val isHuaweiDevice: Boolean get() = Build.MANUFACTURER.equals("HUAWEI", ignoreCase = true) || Build.BRAND.equals("HUAWEI", ignoreCase = true) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt index 9918edc9f1..41ed151c3f 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt @@ -32,7 +32,6 @@ import com.tangem.tap.foregroundActivityObserver import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton @@ -48,7 +47,6 @@ internal class DefaultCardSdkProvider @Inject constructor( private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val dispatchers: CoroutineDispatcherProvider, private val apiConfigsManager: ApiConfigsManager, - appVersionProvider: AppVersionProvider, appInfoProvider: AppInfoProvider, authProvider: AuthProvider, ) : CardSdkProvider, CardSdkOwner { @@ -74,10 +72,7 @@ internal class DefaultCardSdkProvider @Inject constructor( val apiEnvironment = Provider { apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemTech).environment } - val platformHeaders = RequestHeader.AppVersionPlatformHeaders( - appVersionProvider = appVersionProvider, - appInfoProvider = appInfoProvider, - ) + val platformHeaders = RequestHeader.AppVersionPlatformHeaders(appInfoProvider) val apiKeyHeader = RequestHeader.TangemApiKeyHeader(authProvider, apiEnvironment) TangemApiServiceSettings.addInterceptors( AddHeadersInterceptor(platformHeaders.values + apiKeyHeader.values), diff --git a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt index e3e54d1699..bc3f263741 100644 --- a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt +++ b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt @@ -4,7 +4,6 @@ import com.tangem.datasource.api.moonpay.MoonPayApi import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -14,8 +13,7 @@ import com.tangem.tap.domain.scanCard.repository.DefaultScanCardRepository import com.tangem.tap.network.exchangeServices.DefaultRampManager import com.tangem.tap.network.exchangeServices.SellService import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService -import com.tangem.tap.proxy.AppStateHolder -import com.tangem.utils.Provider +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -45,13 +43,13 @@ internal object ActivityModule { @Provides @Singleton fun provideDefaultRampManager( - appStateHolder: AppStateHolder, + sellService: SellService, expressServiceFetcher: ExpressServiceFetcher, currenciesRepository: CurrenciesRepository, dispatchers: CoroutineDispatcherProvider, ): RampStateManager { return DefaultRampManager( - sellService = Provider { requireNotNull(appStateHolder.sellService) }, + sellService = sellService, expressServiceFetcher = expressServiceFetcher, currenciesRepository = currenciesRepository, dispatchers = dispatchers, diff --git a/app/src/main/java/com/tangem/tap/di/AppStateHolderModule.kt b/app/src/main/java/com/tangem/tap/di/AppStateHolderModule.kt deleted file mode 100644 index 267fa733f1..0000000000 --- a/app/src/main/java/com/tangem/tap/di/AppStateHolderModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.tap.di - -import com.tangem.domain.redux.ReduxStateHolder -import com.tangem.tap.proxy.AppStateHolder -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface AppStateHolderModule { - - @Binds - @Singleton - fun bindsReduxStateHolder(appStateHolder: AppStateHolder): ReduxStateHolder -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index cca1b670ed..61da4b0ea5 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -3,6 +3,7 @@ package com.tangem.tap.di import android.content.Context import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.domain.card.BuildConfig +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles @@ -36,6 +37,7 @@ internal class TangemSdkManagerModule { dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, blockchainToDeriveFinder: BlockchainToDeriveFinder, analyticsErrorHandler: AnalyticsErrorHandler, + cardRepository: CardRepository, ): TangemSdkManager { return if (BuildConfig.MOCK_DATA_SOURCE) { MockTangemSdkManager(resources = context.resources) @@ -50,6 +52,7 @@ internal class TangemSdkManagerModule { dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, blockchainToDeriveFinder = blockchainToDeriveFinder, analyticsErrorHandler = analyticsErrorHandler, + cardRepository = cardRepository, ) } } diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 0021307180..492904d18a 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -13,6 +13,8 @@ import com.tangem.tap.common.settings.IntentSettingsManager import com.tangem.tap.common.share.IntentShareManager import com.tangem.tap.common.url.CustomTabsUrlOpener import com.tangem.tap.core.DefaultAppCoroutineScope +import com.tangem.tap.data.DefaultAppInfoProvider +import com.tangem.utils.info.AppInfoProvider import dagger.Binds import dagger.Module import dagger.Provides @@ -28,6 +30,10 @@ internal interface UtilsModule { @Binds fun provideAppScope(defaultAppScope: DefaultAppCoroutineScope): AppCoroutineScope + @Binds + @Singleton + fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider + companion object { @Provides diff --git a/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt index 47e1215497..b02028c5bf 100644 --- a/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt @@ -1,9 +1,7 @@ package com.tangem.tap.di.core.ui import com.tangem.core.ui.DesignFeatureToggles -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.tap.core.ui.DefaultDesignFeatureToggles -import com.tangem.tap.core.ui.DefaultHoldToConfirmButtonFeatureToggles import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,9 +13,4 @@ interface CoreUiBindsModule { @Binds fun bindDesignFeatureToggles(impl: DefaultDesignFeatureToggles): DesignFeatureToggles - - @Binds - fun bindHoldToConfirmButtonFeatureToggles( - impl: DefaultHoldToConfirmButtonFeatureToggles, - ): HoldToConfirmButtonFeatureToggles } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt b/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt index 118bcb16a7..496271ef06 100644 --- a/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt @@ -1,12 +1,18 @@ package com.tangem.tap.di.data +import android.content.Context import com.tangem.data.card.sdk.CardSdkOwner import com.tangem.data.card.sdk.CardSdkProvider +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.tap.data.DefaultCardSdkProvider import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.io.File import javax.inject.Singleton @Module @@ -20,4 +26,22 @@ internal interface CardSdkModule { @Binds @Singleton fun providerCardSdkLifecycleObserver(defaultCardSdkProvider: DefaultCardSdkProvider): CardSdkOwner + + companion object { + + @Provides + @Singleton + fun provideCardArtworksProvider( + sdkRepository: CardSdkConfigRepository, + @ApplicationContext context: Context, + ): CardArtworksProvider { + return CardArtworksProvider( + tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl }, + artworksDirectory = File( + context.getExternalFilesDir(null) ?: context.filesDir, + "card_artworks", + ).apply { mkdirs() }, + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt index 400f58d8b2..f6dc626675 100644 --- a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt @@ -1,13 +1,10 @@ package com.tangem.tap.di.data -import com.tangem.Log -import com.tangem.LogFormat -import com.tangem.TangemSdkLogger import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.tap.common.log.TangemAppLoggerInitializer +import com.tangem.tap.common.log.TangemBlockchainSDKLogger import com.tangem.tap.common.log.TangemCardSDKLogger -import com.tangem.tap.data.TangemBlockchainSDKLogger +import com.tangem.tap.common.log.TangemLoggingInitializer import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,31 +17,10 @@ internal object TangemLoggingModule { @Provides @Singleton - fun provideAppLoggerInitializer(appLogsStore: AppLogsStore): TangemAppLoggerInitializer { - return TangemAppLoggerInitializer(appLogsStore) - } - - @Provides - @Singleton - fun provideCardSDKLogger(appLogsStore: AppLogsStore): TangemSdkLogger { - val logLevels = listOf( - Log.Level.ApduCommand, - Log.Level.Apdu, - Log.Level.Tlv, - Log.Level.Nfc, - Log.Level.Command, - Log.Level.Session, - Log.Level.View, - Log.Level.Network, - Log.Level.Error, - Log.Level.Biometric, - Log.Level.Info, - ) - - return TangemCardSDKLogger( - levels = logLevels, - messageFormatter = LogFormat.StairsFormatter(), + fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer { + return TangemLoggingInitializer( appLogsStore = appLogsStore, + tangemSdkLogger = TangemCardSDKLogger(appLogsStore), ) } diff --git a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt index 1927963c23..c78909a676 100644 --- a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt @@ -1,9 +1,7 @@ package com.tangem.tap.di.data -import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.datasource.local.visa.VisaOTPStorage -import com.tangem.tap.data.DefaultTangemPayStorage import com.tangem.tap.data.DefaultVisaAuthTokenStorage import com.tangem.tap.data.DefaultVisaOTPStorage import dagger.Binds @@ -23,8 +21,4 @@ internal interface VisaStorageModule { @Binds @Singleton fun bindVisaOTPStorage(impl: DefaultVisaOTPStorage): VisaOTPStorage - - @Binds - @Singleton - fun bindTangemPayStorage(impl: DefaultTangemPayStorage): TangemPayStorage } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt new file mode 100644 index 0000000000..9886ff8e2c --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt @@ -0,0 +1,55 @@ +package com.tangem.tap.di.domain + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository +import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase +import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase +import com.tangem.utils.coroutines.AppCoroutineScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AssetsDiscoveryDomainModule { + + @Provides + @Singleton + fun provideObserveAssetsDiscoveryUseCase( + assetsDiscoveryRepository: AssetsDiscoveryRepository, + ): ObserveAssetsDiscoveryUseCase { + return ObserveAssetsDiscoveryUseCase( + assetsDiscoveryRepository = assetsDiscoveryRepository, + ) + } + + @Provides + @Singleton + fun provideAcknowledgeAssetsDiscoveryCompletionUseCase( + assetsDiscoveryRepository: AssetsDiscoveryRepository, + ): AcknowledgeAssetsDiscoveryCompletionUseCase { + return AcknowledgeAssetsDiscoveryCompletionUseCase( + assetsDiscoveryRepository = assetsDiscoveryRepository, + ) + } + + @Provides + @Singleton + fun provideStartAssetsDiscoveryUseCase( + assetsDiscoveryRepository: AssetsDiscoveryRepository, + manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + analyticsEventHandler: AnalyticsEventHandler, + appCoroutineScope: AppCoroutineScope, + ): StartAssetsDiscoveryUseCase { + return StartAssetsDiscoveryUseCase( + assetsDiscoveryRepository = assetsDiscoveryRepository, + manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, + analyticsEventHandler = analyticsEventHandler, + appCoroutineScope = appCoroutineScope, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index 7f99ec03db..45b884766c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -11,7 +11,6 @@ import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.domain.wallets.usecase.* import com.tangem.sdk.api.TangemSdkManager -import com.tangem.tap.domain.card.DefaultDeleteSavedAccessCodesUseCase import com.tangem.tap.domain.card.DefaultResetCardUseCase import dagger.Module import dagger.Provides @@ -56,7 +55,7 @@ internal object CardDomainModule { @Provides @Singleton fun provideDeleteSavedAccessCodesUseCase(tangemSdkManager: TangemSdkManager): DeleteSavedAccessCodesUseCase { - return DefaultDeleteSavedAccessCodesUseCase(tangemSdkManager) + return DeleteSavedAccessCodesUseCase(tangemSdkManager) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt index db3b9aa4e2..5a193f32f7 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt @@ -7,6 +7,7 @@ import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles import com.tangem.tap.domain.scanCard.DefaultScanCardProcessor import com.tangem.tap.domain.scanCard.LegacyScanProcessor +import com.tangem.tap.domain.scanCard.UseCaseScanProcessor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,8 +26,16 @@ internal object CardLegacyDomainModule { @Provides @Singleton - fun provideScanCardProcessor(legacyScanProcessor: LegacyScanProcessor): ScanCardProcessor { - return DefaultScanCardProcessor(legacyScanProcessor = legacyScanProcessor) + fun provideScanCardProcessor( + legacyScanProcessor: LegacyScanProcessor, + useCaseScanProcessor: UseCaseScanProcessor, + cardScanningFeatureToggles: CardScanningFeatureToggles, + ): ScanCardProcessor { + return DefaultScanCardProcessor( + legacyScanProcessor = legacyScanProcessor, + useCaseScanProcessor = useCaseScanProcessor, + cardScanningFeatureToggles = cardScanningFeatureToggles, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt new file mode 100644 index 0000000000..0ab55063f9 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt @@ -0,0 +1,78 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase +import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DynamicAddressesDomainModule { + + @Provides + @Singleton + fun provideEnableDynamicAddressesUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): EnableDynamicAddressesUseCase { + return EnableDynamicAddressesUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideDisableDynamicAddressesUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): DisableDynamicAddressesUseCase { + return DisableDynamicAddressesUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideGetDynamicAddressesStatusUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): GetDynamicAddressesStatusUseCase { + return GetDynamicAddressesStatusUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideGetDynamicReceiveAddressUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): GetDynamicReceiveAddressUseCase { + return GetDynamicReceiveAddressUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideCreateConsolidationTransactionUseCase( + consolidationRepository: ConsolidationRepository, + ): CreateConsolidationTransactionUseCase { + return CreateConsolidationTransactionUseCase(consolidationRepository) + } + + @Provides + @Singleton + fun provideIsXpubSupportedUseCase(walletManagersFacade: WalletManagersFacade): IsXpubSupportedUseCase { + return IsXpubSupportedUseCase(walletManagersFacade) + } + + @Provides + @Singleton + fun provideGetDerivedXpubUseCase( + walletManagersFacade: WalletManagersFacade, + derivationsRepository: DerivationsRepository, + ): GetDerivedXpubUseCase { + return GetDerivedXpubUseCase(walletManagersFacade, derivationsRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index a823079f33..5633c8744b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.staking.* import com.tangem.domain.staking.repositories.* +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.single.SingleStakingBalanceFetcher import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase import com.tangem.domain.walletmanager.WalletManagersFacade @@ -226,8 +227,14 @@ internal object StakingDomainModule { @Provides @Singleton - fun provideStakingIdFactory(walletManagersFacade: WalletManagersFacade): StakingIdFactory { - return StakingIdFactory(walletManagersFacade = walletManagersFacade) + fun provideStakingIdFactory( + walletManagersFacade: WalletManagersFacade, + stakingFeatureToggles: StakingFeatureToggles, + ): StakingIdFactory { + return StakingIdFactory( + walletManagersFacade = walletManagersFacade, + stakingFeatureToggles = stakingFeatureToggles, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt index 8cf51ccfe1..4a30ff772b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt @@ -49,6 +49,18 @@ internal object SwapDomainModule { ) } + @Provides + @Singleton + fun provideGetSwapPairUseCase( + swapRepositoryV2: SwapRepositoryV2, + swapErrorResolver: SwapErrorResolver, + ): GetSwapPairUseCase { + return GetSwapPairUseCase( + swapRepositoryV2 = swapRepositoryV2, + swapErrorResolver = swapErrorResolver, + ) + } + @Provides @Singleton fun provideSelectInitialPairUseCase( diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokenSyncDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokenSyncDomainModule.kt deleted file mode 100644 index 49da21bf6d..0000000000 --- a/app/src/main/java/com/tangem/tap/di/domain/TokenSyncDomainModule.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.tangem.tap.di.domain - -import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import com.tangem.domain.tokensync.usecase.AcknowledgeTokenSyncCompletionUseCase -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase -import com.tangem.utils.coroutines.AppCoroutineScope -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object TokenSyncDomainModule { - - @Provides - @Singleton - fun provideObserveTokenSyncUseCase(tokenSyncRepository: TokenSyncRepository): ObserveTokenSyncUseCase { - return ObserveTokenSyncUseCase( - tokenSyncRepository = tokenSyncRepository, - ) - } - - @Provides - @Singleton - fun provideAcknowledgeTokenSyncCompletionUseCase( - tokenSyncRepository: TokenSyncRepository, - ): AcknowledgeTokenSyncCompletionUseCase { - return AcknowledgeTokenSyncCompletionUseCase( - tokenSyncRepository = tokenSyncRepository, - ) - } - - @Provides - @Singleton - fun provideStartTokenSyncUseCase( - tokenSyncRepository: TokenSyncRepository, - manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, - appCoroutineScope: AppCoroutineScope, - ): StartTokenSyncUseCase { - return StartTokenSyncUseCase( - tokenSyncRepository = tokenSyncRepository, - manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, - appCoroutineScope = appCoroutineScope, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 9b3b5503e7..d88224fe30 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -5,6 +5,9 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.notifications.repository.PushNotificationsRepository @@ -257,10 +260,16 @@ internal object TransactionDomainModule { fun provideReceiveAddressesFactory( getEnsNameUseCase: GetEnsNameUseCase, getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase, + dynamicAddressesRepository: DynamicAddressesRepository, + dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ): ReceiveAddressesFactory { return ReceiveAddressesFactory( getEnsNameUseCase = getEnsNameUseCase, getViewedTokenReceiveWarningUseCase = getViewedTokenReceiveWarningUseCase, + getDynamicReceiveAddressUseCase = getDynamicReceiveAddressUseCase, + dynamicAddressesRepository = dynamicAddressesRepository, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt index fd5d2491d3..780bea965e 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt @@ -1,6 +1,13 @@ package com.tangem.tap.di.domain +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.data.wallets.hot.TangemHotWalletSigner +import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase +import com.tangem.domain.walletconnect.WcTransactionSignerProvider import com.tangem.domain.walletconnect.repository.WalletConnectRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.WcSessionsUseCase @@ -27,4 +34,27 @@ internal object WalletConnectDomainModule { fun providesWcSessionsUseCase(sessionsManager: WcSessionsManager): WcSessionsUseCase { return WcSessionsUseCase(sessionsManager) } + + @Provides + @Singleton + fun providesWcTransactionSignerProvider( + cardSdkConfigRepository: CardSdkConfigRepository, + tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + ): WcTransactionSignerProvider { + return object : WcTransactionSignerProvider { + override fun createSigner(wallet: UserWallet): TransactionSigner { + return when (wallet) { + is UserWallet.Hot -> tangemHotWalletSignerFactory.create(wallet) + is UserWallet.Cold -> { + val card = wallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse), + ) + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index e0b447a00d..f409d2dd20 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -2,8 +2,8 @@ package com.tangem.tap.di.domain import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.usecase.ParseSharedAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase @@ -24,6 +24,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase import com.tangem.operations.attestation.CardArtworksProvider +import com.tangem.tap.domain.DefaultUserWalletSelectedHandler import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -151,14 +152,14 @@ internal object WalletsDomainModule { @Provides @Singleton - fun providesSelectWalletUseCase( - userWalletsListRepository: UserWalletsListRepository, - reduxStateHolder: ReduxStateHolder, - ): SelectWalletUseCase { - return SelectWalletUseCase( - userWalletsListRepository = userWalletsListRepository, - reduxStateHolder = reduxStateHolder, - ) + fun providesSelectWalletUseCase(userWalletsListRepository: UserWalletsListRepository): SelectWalletUseCase { + return SelectWalletUseCase(userWalletsListRepository = userWalletsListRepository) + } + + @Provides + @Singleton + fun providesUserWalletSelectedHandler(handler: DefaultUserWalletSelectedHandler): UserWalletSelectedHandler { + return handler } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt b/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt index d0997c9038..badf261801 100644 --- a/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt +++ b/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.libs.blockchainsdk +import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.data.card.TransactionSignerFactory import com.tangem.tap.common.libs.blockchainsdk.DefaultTransactionSignerFactory import dagger.Module @@ -17,7 +18,9 @@ internal class TransactionSignerFactoryModule { @Provides @Singleton - fun provideTransactionSignerFactory(): TransactionSignerFactory { - return DefaultTransactionSignerFactory() + fun provideTransactionSignerFactory( + lastSignedWalletFormStore: LastSignedWalletFormStore, + ): TransactionSignerFactory { + return DefaultTransactionSignerFactory(lastSignedWalletFormStore) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/DefaultUserWalletSelectedHandler.kt b/app/src/main/java/com/tangem/tap/domain/DefaultUserWalletSelectedHandler.kt new file mode 100644 index 0000000000..31ce9a9df3 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/DefaultUserWalletSelectedHandler.kt @@ -0,0 +1,61 @@ +package com.tangem.tap.domain + +import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletSelectedHandler +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveInAndJoin +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [UserWalletSelectedHandler]. + * + * Runs three side effects on every selection: updates the analytics tracking context, updates the + * Tangem SDK displayed card-id numbers count (cold wallets only), and recomputes the access code + * request policy (cold wallets only). Hot wallets trigger only the tracking-context update. + * + * Invocations are serialised via [JobHolder]: if a new [invoke] arrives while the previous one is + * still running, the previous load is cancelled and the new one replaces it. The method suspends + * until the newly launched load completes. + */ +@Singleton +internal class DefaultUserWalletSelectedHandler @Inject constructor( + private val trackingContextProxy: TrackingContextProxy, + private val tangemSdkManager: TangemSdkManager, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val appCoroutineScope: AppCoroutineScope, +) : UserWalletSelectedHandler { + + private val loadUserWalletDataJob: JobHolder = JobHolder() + + override suspend fun invoke(userWallet: UserWallet) { + appCoroutineScope.launch { loadUserWalletData(userWallet) } + .saveInAndJoin(loadUserWalletDataJob) + } + + private suspend fun loadUserWalletData(userWallet: UserWallet) { + trackingContextProxy.setContext(userWallet) + + if (userWallet is UserWallet.Cold) { + val scanResponse = userWallet.scanResponse + tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse) + updateAccessCodeRequestPolicy(scanResponse) + } + } + + private suspend fun updateAccessCodeRequestPolicy(scanResponse: ScanResponse) { + val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() + + cardSdkConfigRepository.setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = shouldSaveAccessCodes && scanResponse.card.isAccessCodeSet, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt b/app/src/main/java/com/tangem/tap/domain/TapErrors.kt deleted file mode 100644 index 2f1677fc5d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.tap.domain - -import androidx.annotation.StringRes -import com.tangem.common.core.TangemError -import com.tangem.wallet.R - -interface TapErrors - -interface ArgError { - val args: List? -} - -interface MultiMessageError : TapErrors { - val errorList: List - val builder: (List) -> String -} - -sealed class TapError( - @StringRes val messageResource: Int, - override val args: List? = null, -) : Throwable(), TapErrors, ArgError { - - class UnknownError : TapError(R.string.send_error_unknown) - open class CustomError(val customMessage: String) : TapError(R.string.common_custom_string, listOf(customMessage)) - - class NoInternetConnection : TapError(R.string.wallet_notification_no_internet) - - sealed class WalletManager { - class NoAccountError(amountToCreateAccount: String) : CustomError(amountToCreateAccount) - class InternalError(message: String) : CustomError(message) - class BlockchainIsUnreachableTryLater : TapError(R.string.wallet_balance_blockchain_unreachable_try_later) - } -} - -sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) { - override var customMessage: String = code.toString() - - class CardForDifferentApp : TapSdkError(R.string.alert_unsupported_card) - class CardNotSupportedByRelease : TapSdkError(R.string.error_wrong_card_type) -} - -fun TapErrors.assembleErrors(): MutableList?>> { - val idList = mutableListOf?>>() - when (this) { - is MultiMessageError -> this.errorList.forEach { idList.addAll(it.assembleErrors()) } - is TapError -> idList.add(Pair(this.messageResource, this.args)) - } - return idList -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapSdkError.kt b/app/src/main/java/com/tangem/tap/domain/TapSdkError.kt new file mode 100644 index 0000000000..9ce9469d75 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/TapSdkError.kt @@ -0,0 +1,11 @@ +package com.tangem.tap.domain + +import com.tangem.common.core.TangemError +import com.tangem.wallet.R + +sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) { + override var customMessage: String = code.toString() + + class CardForDifferentApp : TapSdkError(R.string.alert_unsupported_card) + class CardNotSupportedByRelease : TapSdkError(R.string.error_wrong_card_type) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt b/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt deleted file mode 100644 index f486cce85c..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.tangem.tap.domain - -import com.tangem.blockchain.common.Token -import com.tangem.blockchain.common.Wallet -import com.tangem.domain.common.extensions.withMainContext -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class TapWalletManager( - private val dispatchers: CoroutineDispatcherProvider = AppCoroutineDispatcherProvider(), -) { - - private var loadUserWalletDataJob: Job? = null - set(value) { - field?.cancel() - field = value - } - - suspend fun onWalletSelected(userWallet: UserWallet) { - // If a previous job was running, it gets cancelled before the new one starts, - // ensuring that only one job is active at any given time. - loadUserWalletDataJob = CoroutineScope(dispatchers.io) - .launch { loadUserWalletData(userWallet) } - .apply { join() } - } - - /** - * [REDACTED_TODO_COMMENT] - */ - private suspend fun loadUserWalletData(userWallet: UserWallet) { - val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy) - trackingContextProxy.setContext(userWallet) - - if (userWallet is UserWallet.Cold) { - val scanResponse = userWallet.scanResponse - tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse) - withMainContext { - // Order is important - store.dispatch(GlobalAction.SaveScanResponse(scanResponse)) - } - } - } -} - -fun Wallet.getFirstToken(): Token? = getTokens().toList().getOrNull(index = 0) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/card/DefaultDeleteSavedAccessCodesUseCase.kt b/app/src/main/java/com/tangem/tap/domain/card/DefaultDeleteSavedAccessCodesUseCase.kt deleted file mode 100644 index 9a89db05b8..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/card/DefaultDeleteSavedAccessCodesUseCase.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.tap.domain.card - -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.common.doOnFailure -import com.tangem.common.doOnSuccess -import com.tangem.domain.card.DeleteSavedAccessCodesUseCase -import com.tangem.sdk.api.TangemSdkManager - -internal class DefaultDeleteSavedAccessCodesUseCase( - private val tangemSdkManager: TangemSdkManager, -) : DeleteSavedAccessCodesUseCase { - - override suspend fun invoke(cardId: String): Either { - tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) - .doOnFailure { return it.left() } - .doOnSuccess { return Unit.right() } - - return Unit.right() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/model/Currency.kt b/app/src/main/java/com/tangem/tap/domain/model/Currency.kt index 29c89811fc..4de663c36c 100644 --- a/app/src/main/java/com/tangem/tap/domain/model/Currency.kt +++ b/app/src/main/java/com/tangem/tap/domain/model/Currency.kt @@ -1,12 +1,11 @@ package com.tangem.tap.domain.model -import com.tangem.tap.common.redux.global.CryptoCurrencyName import com.tangem.blockchain.common.Blockchain as SdkBlockchain import com.tangem.blockchain.common.Token as SdkToken sealed interface Currency { val blockchain: SdkBlockchain - val currencySymbol: CryptoCurrencyName + val currencySymbol: String val derivationPath: String? val decimals get() = when (this) { @@ -26,6 +25,6 @@ sealed interface Currency { override val blockchain: SdkBlockchain, override val derivationPath: String?, ) : Currency { - override val currencySymbol: CryptoCurrencyName = blockchain.currency + override val currencySymbol: String = blockchain.currency } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/model/PendingTransaction.kt b/app/src/main/java/com/tangem/tap/domain/model/PendingTransaction.kt deleted file mode 100644 index 8548b11eba..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/model/PendingTransaction.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.tap.domain.model - -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionStatus -import com.tangem.blockchain.common.Wallet - -data class PendingTransaction( - val transactionData: TransactionData.Uncompiled, - val type: PendingTransactionType, -) { - val address: String? = when (type) { - PendingTransactionType.Incoming -> nullIfUnknown(transactionData.sourceAddress) - PendingTransactionType.Outgoing -> nullIfUnknown(transactionData.destinationAddress) - PendingTransactionType.Unknown -> null - } - - val currency: String = transactionData.amount.currencySymbol - - private fun nullIfUnknown(address: String): String? = if (address == "unknown") null else address -} - -enum class PendingTransactionType { Incoming, Outgoing, Unknown } - -fun TransactionData.Uncompiled.toPendingTransaction(walletAddress: String): PendingTransaction? { - if (this.status == TransactionStatus.Confirmed) return null - - val type: PendingTransactionType = when { - this.sourceAddress == walletAddress -> PendingTransactionType.Outgoing - this.destinationAddress == walletAddress -> PendingTransactionType.Incoming - else -> PendingTransactionType.Unknown - } - return PendingTransaction(this, type) -} - -fun List.toPendingTransactions(walletAddress: String): List { - return this.mapNotNull { it.toPendingTransaction(walletAddress) } -} - -fun Wallet.getPendingTransactions(type: PendingTransactionType? = null): List { - val txs = recentTransactions.toPendingTransactions(address) - return when (type) { - null -> txs - else -> txs.filter { it.type == type } - } -} - -fun Wallet.hasPendingTransactions(): Boolean { - return getPendingTransactions().isNotEmpty() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/model/WalletAddressData.kt b/app/src/main/java/com/tangem/tap/domain/model/WalletAddressData.kt deleted file mode 100644 index a24fe98346..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/model/WalletAddressData.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.domain.model - -import com.tangem.blockchain.common.address.AddressType - -internal data class WalletAddressData( - val address: String, - val type: AddressType, - val shareUrl: String, - val exploreUrl: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt index 51e1ece9e8..8244562c3a 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt @@ -5,16 +5,15 @@ import com.tangem.common.core.TangemError import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store // TODO: Remove this object after feature toggle was removed and use ScanCardUseCase instead internal class DefaultScanCardProcessor( private val legacyScanProcessor: LegacyScanProcessor, + private val useCaseScanProcessor: UseCaseScanProcessor, + private val cardScanningFeatureToggles: CardScanningFeatureToggles, ) : ScanCardProcessor { private val isNewCardScanningEnabled: Boolean - get() = store.inject(DaggerGraphState::cardScanningFeatureToggles).isNewCardScanningEnabled + get() = cardScanningFeatureToggles.isNewCardScanningEnabled override suspend fun scan( cardId: String?, @@ -23,7 +22,7 @@ internal class DefaultScanCardProcessor( shouldCheckIsAlreadyActivated: Boolean, ): CompletionResult { return if (isNewCardScanningEnabled) { - UseCaseScanProcessor.scan(cardId, allowsRequestAccessCodeFromRepository) + useCaseScanProcessor.scan(cardId, allowsRequestAccessCodeFromRepository) } else { legacyScanProcessor.scan( analyticsSource = analyticsSource, @@ -46,7 +45,7 @@ internal class DefaultScanCardProcessor( onSuccess: suspend (scanResponse: ScanResponse) -> Unit, ) { if (isNewCardScanningEnabled) { - UseCaseScanProcessor.scan( + useCaseScanProcessor.scan( analyticsSource = analyticsSource, cardId = cardId, onProgressStateChange = onProgressStateChange, diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index 40f0ad6457..c26c10205c 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -6,6 +6,7 @@ import com.tangem.common.core.TangemSdkError import com.tangem.common.doOnFailure import com.tangem.common.doOnSuccess import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent @@ -22,16 +23,14 @@ import com.tangem.core.ui.message.dialog.Dialogs import com.tangem.domain.card.ScanFailsCounter import com.tangem.domain.card.common.util.twinsIsTwinned import com.tangem.domain.common.extensions.withMainContext +import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.sdk.extensions.localizedDescriptionRes import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.mainScope -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store import com.tangem.tap.tangemSdkManager import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay @@ -40,11 +39,16 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton +@Suppress("LongParameterList") internal class LegacyScanProcessor @Inject constructor( @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, private val trackingContextProxy: TrackingContextProxy, private val scanFailsCounter: ScanFailsCounter, + private val appRouter: AppRouter, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase, + private val onboardingHelper: OnboardingHelper, ) { suspend fun scan( @@ -152,7 +156,7 @@ internal class LegacyScanProcessor @Inject constructor( mainScope.launch { onCancel() - store.inject(DaggerGraphState::sendFeedbackEmailUseCase).invoke( + sendFeedbackEmailUseCase.invoke( type = FeedbackEmailType.CardAttestationFailed, ) } @@ -165,14 +169,13 @@ internal class LegacyScanProcessor @Inject constructor( } } - @Suppress("LongMethod", "LongParameterList", "MagicNumber") private suspend inline fun onScanSuccess( scanResponse: ScanResponse, crossinline onProgressStateChange: suspend (showProgress: Boolean) -> Unit, crossinline onWalletNotCreated: suspend () -> Unit, crossinline onSuccess: suspend (ScanResponse) -> Unit, ) { - if (OnboardingHelper.isOnboardingCase(scanResponse)) { + if (onboardingHelper.isOnboardingCase(scanResponse)) { trackingContextProxy.addContext(scanResponse) onWalletNotCreated() navigateTo( @@ -184,8 +187,7 @@ internal class LegacyScanProcessor @Inject constructor( } else { trackingContextProxy.setContext(scanResponse) - val wasTwinsOnboardingShown = - store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase).invokeSync() + val wasTwinsOnboardingShown = wasTwinsOnboardingShownUseCase.invokeSync() if (scanResponse.twinsIsTwinned() && !wasTwinsOnboardingShown) { onWalletNotCreated() @@ -204,7 +206,7 @@ internal class LegacyScanProcessor @Inject constructor( private suspend inline fun navigateTo(route: AppRoute, onProgressStateChange: (showProgress: Boolean) -> Unit) { delay(DELAY_SDK_DIALOG_CLOSE) - store.dispatchNavigationAction { push(route) } + appRouter.push(route) onProgressStateChange(false) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt index 43eb6bd70c..18fddb40e4 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt @@ -4,33 +4,47 @@ import arrow.fx.coroutines.resourceScope import com.tangem.common.CompletionResult import com.tangem.common.core.TangemError import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.ExceptionAnalyticsEvent +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.domain.card.ScanCardException +import com.tangem.domain.card.ScanCardUseCase +import com.tangem.domain.card.ScanFailsRequester import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.domain.scanCard.chains.* +import com.tangem.tap.domain.scanCard.chains.AnalyticsChain +import com.tangem.tap.domain.scanCard.chains.CheckForOnboardingChain +import com.tangem.tap.domain.scanCard.chains.FailedScansCounterChain +import com.tangem.tap.domain.scanCard.chains.ScanChainException import com.tangem.tap.domain.scanCard.utils.ScanCardExceptionConverter -import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.scope -import com.tangem.tap.store import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton -internal object UseCaseScanProcessor { +@Singleton +@Suppress("LongParameterList") +internal class UseCaseScanProcessor @Inject constructor( + private val scanCardUseCase: ScanCardUseCase, + private val scanFailsRequester: ScanFailsRequester, + private val appRouter: AppRouter, + private val trackingContextProxy: TrackingContextProxy, + private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase, + private val onboardingHelper: OnboardingHelper, +) { private val scanCardExceptionConverter = ScanCardExceptionConverter() suspend fun scan( cardId: String? = null, allowsRequestAccessCodeFromRepository: Boolean = false, ): CompletionResult { - val scanCardUseCase = store.inject(DaggerGraphState::scanCardUseCase) - return scanCardUseCase(cardId, allowsRequestAccessCodeFromRepository) .fold( ifLeft = { scanCardException -> @@ -52,7 +66,6 @@ internal object UseCaseScanProcessor { onFailure: suspend (error: TangemError) -> Unit, onSuccess: suspend (scanResponse: ScanResponse) -> Unit, ) = progressScope(onProgressStateChange) { - val scanCardUseCase = store.inject(DaggerGraphState::scanCardUseCase) val chains = buildList { add( FailedScansCounterChain( @@ -60,7 +73,7 @@ internal object UseCaseScanProcessor { ), ) add(AnalyticsChain(Basic.CardWasScanned(analyticsSource))) - add(CheckForOnboardingChain(store)) + add(CheckForOnboardingChain(trackingContextProxy, wasTwinsOnboardingShownUseCase, onboardingHelper)) } scanCardUseCase(cardId, afterScanChains = chains).fold( @@ -71,7 +84,7 @@ internal object UseCaseScanProcessor { private fun showScanFailsDialog(source: AnalyticsParam.ScreensSources) { scope.launch { - store.inject(DaggerGraphState::scanFailsRequester).show(source) + scanFailsRequester.show(source) } } @@ -84,7 +97,6 @@ internal object UseCaseScanProcessor { is ScanCardException.ChainException -> proceedWithScanChainException( exception, onWalletNotCreated, - onFailure, ) is ScanCardException.UnknownException, is ScanCardException.UserCancelled, @@ -107,16 +119,12 @@ internal object UseCaseScanProcessor { private suspend fun proceedWithScanChainException( exception: ScanCardException.ChainException, onWalletNotCreated: suspend () -> Unit, - onFailure: suspend (error: TangemError) -> Unit, ) { when (exception) { is ScanChainException.OnboardingNeeded -> { navigateTo(exception.onboardingRoute) onWalletNotCreated() } - is ScanChainException.DisclaimerWasCanceled -> { - onFailure(scanCardExceptionConverter.convertBack(exception)) - } } } @@ -134,6 +142,6 @@ internal object UseCaseScanProcessor { private suspend inline fun navigateTo(route: AppRoute) { delay(DELAY_SDK_DIALOG_CLOSE) - store.dispatchNavigationAction { push(route) } + appRouter.push(route) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt index a3693ba6d2..6576186613 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt @@ -3,18 +3,16 @@ package com.tangem.tap.domain.scanCard.chains import arrow.core.left import arrow.core.right import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.domain.card.ScanCardException import com.tangem.domain.card.common.util.twinsIsTwinned import com.tangem.domain.core.chain.Chain import com.tangem.domain.core.chain.ResultChain import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.tap.features.onboarding.OnboardingHelper -import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay -import org.rekotlin.Store /** * Handles the verification process to determine if the scanned card requires onboarding. @@ -22,19 +20,17 @@ import org.rekotlin.Store * Returns: * - [ScanChainException.OnboardingNeeded] if onboarding required. * - * @param store the [Store] that holds the state of the app. - * * @see Chain for more information about the Chain interface. */ class CheckForOnboardingChain( - private val store: Store, + private val trackingContextProxy: TrackingContextProxy, + private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase, + private val onboardingHelper: OnboardingHelper, ) : ResultChain() { override suspend fun launch(previousChainResult: ScanResponse): ScanChainResult { - val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy) - return when { - OnboardingHelper.isOnboardingCase(previousChainResult) -> { + onboardingHelper.isOnboardingCase(previousChainResult) -> { trackingContextProxy.addContext(previousChainResult) ScanChainException.OnboardingNeeded( AppRoute.Onboarding( @@ -46,8 +42,7 @@ class CheckForOnboardingChain( else -> { trackingContextProxy.setContext(previousChainResult) - val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase) - .invokeSync() + val wasTwinsOnboardingShown = wasTwinsOnboardingShownUseCase.invokeSync() // If twins was twinned previously but twins welcome not shown if (previousChainResult.twinsIsTwinned() && !wasTwinsOnboardingShown) { diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt index cd7a62cab3..31c35c54de 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt @@ -5,15 +5,6 @@ import com.tangem.domain.card.ScanCardException sealed class ScanChainException : ScanCardException.ChainException() { - /** - * May be returned from [DisclaimerChain] - * */ - class DisclaimerWasCanceled : ScanChainException() { - - @Suppress("UnusedPrivateMember") - private fun readResolve(): Any = DisclaimerWasCanceled() - } - /** * May be returned from [CheckForOnboardingChain] * diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt index a7fd680230..711c8c9e8e 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt @@ -33,7 +33,6 @@ internal class ScanCardExceptionConverter : TwoWayConverter TangemSdkError.UserCancelled() is ScanChainException.OnboardingNeeded, null, -> TangemSdkError.ExceptionError(e?.cause) diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 29a181c404..6211ed6b11 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -23,6 +23,7 @@ import com.tangem.crypto.bip39.DefaultMnemonic import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse @@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager( private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, private val blockchainToDeriveFinder: BlockchainToDeriveFinder, private val analyticsErrorHandler: AnalyticsErrorHandler, + private val cardRepository: CardRepository, ) : TangemSdkManager { private val tangemSdk: TangemSdk @@ -146,6 +148,7 @@ internal class DefaultTangemSdkManager( shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, onboardingV2FeatureToggles = onboardingV2FeatureToggles, + cardRepository = cardRepository, ), cardId = cardId, initialMessage = message, @@ -453,6 +456,7 @@ internal class DefaultTangemSdkManager( twinPublicKey = secondCardPublicKey, issuerKeys = issuerKeyPair, isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, + cardRepository = cardRepository, ), cardId = cardId, initialMessage = initialMessage, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index c70dc421f4..bc15466208 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -10,6 +10,7 @@ import com.tangem.common.KeyPair import com.tangem.common.SuccessResponse import com.tangem.common.authentication.keystore.DummyKeystoreManager import com.tangem.common.core.CardSessionRunnable +import com.tangem.common.core.TangemSdkError import com.tangem.common.core.UserCodeRequestPolicy import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.services.InMemoryStorage @@ -32,6 +33,8 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.visa.VisaCardActivationResponse import com.tangem.sdk.api.visa.VisaCardActivationTaskMode import com.tangem.tap.domain.sdk.mocks.MockProvider +import com.tangem.tap.domain.sdk.mocks.showMockCardPicker +import com.tangem.tap.foregroundActivityObserver @Suppress("TooManyFunctions") class MockTangemSdkManager( @@ -61,6 +64,17 @@ class MockTangemSdkManager( allowsRequestAccessCodeFromRepository: Boolean, shouldCheckIsAlreadyActivated: Boolean, ): CompletionResult { + if (!MockProvider.isPreset) { + val activity = foregroundActivityObserver.foregroundActivity + if (activity != null) { + val selectedMock = showMockCardPicker(activity) + if (selectedMock != null) { + MockProvider.setMocksWithoutPresetFlag(selectedMock) + } else { + return CompletionResult.Failure(TangemSdkError.UserCancelled()) + } + } + } return MockProvider.getScanResponse() } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockCardPickerDialog.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockCardPickerDialog.kt new file mode 100644 index 0000000000..301a9214d2 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockCardPickerDialog.kt @@ -0,0 +1,33 @@ +package com.tangem.tap.domain.sdk.mocks + +import androidx.appcompat.app.AlertDialog +import com.tangem.wallet.R +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +internal suspend fun showMockCardPicker(activity: AppCompatActivity): MockContent? = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + val mocks = MockProvider.availableMocks + val names = mocks.map { it.first }.toTypedArray() + + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.mock_card_picker_title) + .setItems(names) { _, which -> + if (continuation.isActive) { + continuation.resume(mocks[which].second) + } + } + .setOnCancelListener { + if (continuation.isActive) { + continuation.resume(null) + } + } + .create() + + continuation.invokeOnCancellation { activity.runOnUiThread { dialog.dismiss() } } + dialog.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index 704c10dd50..2e8dcfbea1 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -11,10 +11,41 @@ object MockProvider { private var content: MockContent = getMockContent(ProductType.Wallet) + var isPreset: Boolean = false + private set + private var isEmulatingError: Boolean = false private var emulatedError: TangemError = TangemSdkError.TagLost() + val availableMocks: List> = listOf( + "Wallet" to WalletMockContent, + "Note" to NoteMockContent, + "Twins" to TwinsMockContent, + "Ring" to RingMockContent, + "Wallet 2" to Wallet2MockContent, + "Wallet 2 (No Backup)" to Wallet2NoBackupMockContent, + "Wallet 2 (No Backup, No Wallets)" to Wallet2NoBackupNoWalletsMockContent, + "Wallet 2 (Seed Phrase)" to Wallet2WithSeedPhraseMockContent, + "Wallet 2 (With derivations)" to Wallet2WithDerivationsMockContent, + "Shiba" to ShibaMockContent, + "Shiba (No Backup)" to ShibaNoBackupMockContent, + "Shiba (No Backup, No Wallets)" to ShibaNoBackupNoWalletsMockContent, + "Ed25519 Curve" to EdCurveMockContent, + "Secp256k1 Curve" to Secpk1CurveMockContent, + "Backup Wallet" to BackupWalletMockContent, + "Dev Wallet" to DevWalletMockContent, + "Firmware 4.12" to Firmware412MockContent, + "French Blue (Triple)" to FrenchBlueMockContent, + "French White (Double)" to FrenchWhiteMockContent, + "Football Black (Double)" to FootballBlackMockContent, + "Football Dark Green (Triple)" to FootballDarkGreenMockContent, + "Metaplanet (Triple)" to MetaplanetMockContent, + "Metaplanet (Double)" to MetaplanetDoubleMockContent, + "Red Panda (Triple)" to RedPandaMockContent, + "Red Panda (Double)" to RedPandaDoubleMockContent, + ) + fun setEmulateError(error: TangemError? = null) { isEmulatingError = true error?.let { @@ -28,10 +59,16 @@ object MockProvider { fun setMocks(productType: ProductType) { content = getMockContent(productType) + isPreset = true } fun setMocks(mockContent: MockContent) { content = mockContent + isPreset = true + } + + fun setMocksWithoutPresetFlag(mockContent: MockContent) { + content = mockContent } fun getSuccessResponse() = CompletionResult.Success(content.successResponse).orFailure() diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballBlackMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballBlackMockContent.kt new file mode 100644 index 0000000000..78baf5417f --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballBlackMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FootballBlackMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99009000000000", + batchId = "AF990090", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99009000000000", + batchId = "AF990090", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99009000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballDarkGreenMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballDarkGreenMockContent.kt new file mode 100644 index 0000000000..fc45aedf6a --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballDarkGreenMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FootballDarkGreenMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99008900000000", + batchId = "AF990089", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99008900000000", + batchId = "AF990089", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99008900000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchBlueMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchBlueMockContent.kt new file mode 100644 index 0000000000..5b75f63266 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchBlueMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FrenchBlueMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99008400000000", + batchId = "AF990084", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99008400000000", + batchId = "AF990084", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99008400000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchWhiteMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchWhiteMockContent.kt new file mode 100644 index 0000000000..0ececd498e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchWhiteMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FrenchWhiteMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99008500000000", + batchId = "AF990085", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99008500000000", + batchId = "AF990085", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99008500000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetDoubleMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetDoubleMockContent.kt new file mode 100644 index 0000000000..8680adaf4c --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetDoubleMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object MetaplanetDoubleMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00004000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetMockContent.kt new file mode 100644 index 0000000000..37bc112bb5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object MetaplanetMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00004000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaDoubleMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaDoubleMockContent.kt new file mode 100644 index 0000000000..89c0ddb763 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaDoubleMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object RedPandaDoubleMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00003800000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaMockContent.kt new file mode 100644 index 0000000000..350319d3c8 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object RedPandaMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00003800000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithDerivationsMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithDerivationsMockContent.kt new file mode 100644 index 0000000000..bc976b765a --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithDerivationsMockContent.kt @@ -0,0 +1,59 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.card.EllipticCurve +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent + +/** + * Mock content for UI tests that need the Wallet 2 derivation style (V3) AND the full set of derivations + * provided by [WalletMockContent]. + * + * Why it exists: + * - [WalletMockContent] is Wallet 1 (derivation style V2), so for Bitcoin the card's default path resolves to + * `m/44'/0'/0'/0/0`. WireMock `/user-tokens` stubs send `m/84'/0'/0'/0/0` → the path does not match the card default + * → `Network.DerivationPath.Custom` → `CryptoCurrency.isCustom == true`. That hides the swap-to-another-token button + * introduced in the Send flow and causes the Swap receive card to stay in Empty/Loading state. + * - [Wallet2MockContent] is V3 (matches WireMock) but its `derivationTaskResponse` is keyed by the wrong wallet + * public key (inherited from [WalletMockContent]) and only contains a handful of derivation paths. As a result + * address synchronisation fails in tests that need more than BTC/ETH/BCH/DOGE. + * + * This mock combines both: Wallet 2 card DTO from [Wallet2MockContent] (V3 derivation style) + full derivation + * entries from [WalletMockContent] re-keyed to [Wallet2MockContent]'s own wallet public keys. + */ +object Wallet2WithDerivationsMockContent : MockContent by Wallet2MockContent { + + private val secp256k1Pubkey: ByteArray = + Wallet2MockContent.cardDto.wallets.first { it.curve == EllipticCurve.Secp256k1 }.publicKey + + private val ed25519Pubkey: ByteArray = + Wallet2MockContent.cardDto.wallets.first { it.curve == EllipticCurve.Ed25519 }.publicKey + + override val derivationTaskResponse: DerivationTaskResponse = DerivationTaskResponse( + entries = rekey(WalletMockContent.derivationTaskResponse.entries), + ) + + override val createProductWalletTaskResponse: CreateProductWalletTaskResponse = + CreateProductWalletTaskResponse( + card = Wallet2MockContent.cardDto, + derivedKeys = rekey(WalletMockContent.createProductWalletTaskResponse.derivedKeys), + primaryCard = Wallet2MockContent.createProductWalletTaskResponse.primaryCard, + ) + + /** + * Takes an entry map keyed by [WalletMockContent]'s wallet public keys (Secp256k1 first, Ed25519 second in + * insertion order) and re-keys it to [Wallet2MockContent]'s own wallet public keys so that + * [com.tangem.data.wallets.derivations.DerivationsSource] lookups by card wallet pubkey succeed. + */ + private fun rekey( + sourceEntries: Map, + ): Map { + val values = sourceEntries.values.toList() + return buildMap { + values.getOrNull(index = 0)?.let { put(ByteArrayKey(secp256k1Pubkey), it) } + values.getOrNull(index = 1)?.let { put(ByteArrayKey(ed25519Pubkey), it) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index 382b50ad26..e6d872be54 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -24,6 +24,9 @@ import java.util.Date @Suppress("LargeClass") object WalletMockContent : MockContent { + private val secp256k1WalletPublicKey = + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5) + private val primaryCard = PrimaryCard( cardId = "AC05000000086747", batchId = "AC05", @@ -100,7 +103,7 @@ object WalletMockContent : MockContent { ), wallets = listOf( CardDTO.Wallet( - publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + publicKey = secp256k1WalletPublicKey, chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), curve = EllipticCurve.Secp256k1, settings = CardWallet.Settings(isPermanent = false), @@ -117,11 +120,15 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), ), + DerivationPath("m/44'/60'/0'/0/1") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), - DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( // Tron Network publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), @@ -137,7 +144,7 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), - DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), @@ -151,7 +158,7 @@ object WalletMockContent : MockContent { ), ), extendedPublicKey = ExtendedPublicKey( - publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + publicKey = secp256k1WalletPublicKey, chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), ), isImported = false, @@ -193,9 +200,7 @@ object WalletMockContent : MockContent { override val derivationTaskResponse = DerivationTaskResponse( entries = mapOf( - ByteArrayKey( - byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), - ) + ByteArrayKey(secp256k1WalletPublicKey) to ExtendedPublicKeysMap( mapOf( @@ -220,6 +225,20 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/60'/0'/0/1") to ExtendedPublicKey( // eth (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/3") to ExtendedPublicKey( // eth (account 3) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), @@ -241,7 +260,14 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), - DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( // Tron (account 0) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/1'/0/0") to ExtendedPublicKey( // Tron (account 2) publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), depth = 0, @@ -269,6 +295,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/111111'/1'/0/0") to ExtendedPublicKey( // Kaspa (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -305,7 +338,14 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), - DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2) publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), depth = 0, @@ -386,6 +426,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/60'/0'/0/3") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), @@ -407,7 +454,30 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/111111'/1'/0/0") to ExtendedPublicKey( // Kaspa (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, + 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67,), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, + 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67,), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( // Tron (account 1) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/1'/0/0") to ExtendedPublicKey( // Tron (account 2) publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), depth = 0, @@ -436,9 +506,16 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), - DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana - publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), - chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), depth = 0, parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index 266bfaa86b..78d3151844 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -21,6 +21,7 @@ import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins import com.tangem.domain.card.common.TapWorkarounds.isVisa import com.tangem.domain.card.common.TwinsHelper import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX @@ -34,13 +35,10 @@ import com.tangem.operations.backup.StartPrimaryCardLinkingTask import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask import com.tangem.operations.files.ReadFilesTask import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand -import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.TapSdkError import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.mainScope -import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope -import com.tangem.tap.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -53,6 +51,7 @@ internal class ScanProductTask( private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?, private val shouldCheckIsAlreadyActivated: Boolean, private val isDynamicAddressesEnabled: Boolean, + private val cardRepository: CardRepository, override val allowsRequestAccessCodeFromRepository: Boolean = false, ) : CardSessionRunnable { @@ -80,7 +79,11 @@ internal class ScanProductTask( readVisaCard( session = session, cardDto = cardDto, - scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled), + scanWalletProcessor = ScanWalletProcessor( + blockchainToDeriveFinder = blockchainToDeriveFinder, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, + cardRepository = cardRepository, + ), callback = callback, ) return @@ -88,7 +91,11 @@ internal class ScanProductTask( val commandProcessor = when { cardDto.isTangemTwins -> ScanTwinProcessor() - else -> ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled) + else -> ScanWalletProcessor( + blockchainToDeriveFinder = blockchainToDeriveFinder, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, + cardRepository = cardRepository, + ) } commandProcessor.proceed(cardDto, session) { processorResult -> when (processorResult) { @@ -114,7 +121,7 @@ internal class ScanProductTask( return if (shouldCheckIsAlreadyActivated) { PreflightReadMode.FullCardReadWithAccessCodeCheck } else { - return super.preflightReadMode() + super.preflightReadMode() } } @@ -171,6 +178,7 @@ internal class ScanProductTask( private class ScanWalletProcessor( private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, private val isDynamicAddressesEnabled: Boolean, + private val cardRepository: CardRepository, ) : ProductCommandProcessor { var primaryCard: PrimaryCard? = null @@ -262,8 +270,7 @@ private class ScanWalletProcessor( callback: (result: CompletionResult) -> Unit, ) { mainScope.launch { - val isActivationInProgress = store.inject(DaggerGraphState::cardRepository) - .isActivationInProgress(card.cardId) + val isActivationInProgress = cardRepository.isActivationInProgress(card.cardId) @Suppress("ComplexCondition") if (card.backupStatus == CardDTO.BackupStatus.NoBackup && diff --git a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index cc707e1fce..c2c044dd95 100644 --- a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt @@ -4,6 +4,7 @@ import com.tangem.common.CompletionResult import com.tangem.common.KeyPair import com.tangem.common.core.CardSession import com.tangem.common.core.CardSessionRunnable +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.operations.PreflightReadMode import com.tangem.operations.PreflightReadTask @@ -13,6 +14,7 @@ class FinalizeTwinTask( private val twinPublicKey: ByteArray, private val issuerKeys: KeyPair, private val isDynamicAddressesEnabled: Boolean, + private val cardRepository: CardRepository, ) : CardSessionRunnable { override val allowsRequestAccessCodeFromRepository: Boolean = false @@ -33,8 +35,9 @@ class FinalizeTwinTask( visaCardScanHandler = null, visaCoroutineScope = null, shouldCheckIsAlreadyActivated = false, - isDynamicAddressesEnabled = false, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, onboardingV2FeatureToggles = null, + cardRepository = cardRepository, ).run(session, callback) is CompletionResult.Failure -> callback(CompletionResult.Failure(readResult.error)) diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt index d7d9089767..f9323c265d 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt @@ -9,6 +9,7 @@ import com.tangem.common.services.secure.SecureStorage import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.hotwallet.repository.HotWalletRepository import com.tangem.domain.models.scan.serialization.* @@ -32,6 +33,7 @@ import com.tangem.tap.tangemSdkManager import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Lazy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -56,6 +58,7 @@ internal object UserWalletsListRepositoryModule { analyticsEventHandler: AnalyticsEventHandler, hotWalletRepository: HotWalletRepository, mobileWalletPromoRepository: MobileWalletPromoRepository, + userWalletSelectedHandler: Lazy, ): UserWalletsListRepository { val moshi = buildMoshi() val secureStorage = buildSecureStorage(applicationContext = applicationContext) @@ -107,6 +110,7 @@ internal object UserWalletsListRepositoryModule { analyticsEventHandler = analyticsEventHandler, hotWalletRepository = hotWalletRepository, mobileWalletPromoRepository = mobileWalletPromoRepository, + userWalletSelectedHandler = userWalletSelectedHandler, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 5b02dce01c..54a235ba71 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -13,6 +13,7 @@ import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletTransformAction import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod @@ -38,6 +39,7 @@ import com.tangem.utils.ProviderSuspend import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.extensions.addOrReplace import com.tangem.utils.extensions.indexOfFirstOrNull +import dagger.Lazy import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -61,6 +63,7 @@ internal class DefaultUserWalletsListRepository( private val analyticsEventHandler: AnalyticsEventHandler, private val hotWalletRepository: HotWalletRepository, private val mobileWalletPromoRepository: MobileWalletPromoRepository, + private val userWalletSelectedHandler: Lazy, ) : UserWalletsListRepository { override val userWallets = MutableStateFlow?>(null) @@ -89,15 +92,13 @@ internal class DefaultUserWalletsListRepository( .map { wallets.updateWith(it) } } .doOnSuccess { loadedWallets -> - userWallets.update { _ -> - val selectedUserWalletId = selectedUserWalletRepository.get() - selectedUserWallet.value = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId } - ?: loadedWallets.firstOrNull()?.also { - selectedUserWalletRepository.set(it.walletId) - } - - loadedWallets - } + val selectedUserWalletId = selectedUserWalletRepository.get() + val initialSelection = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId } + ?: loadedWallets.firstOrNull()?.also { + selectedUserWalletRepository.set(it.walletId) + } + setSelectedUserWallet(initialSelection) + userWallets.value = loadedWallets } } } @@ -118,7 +119,7 @@ internal class DefaultUserWalletsListRepository( val userWallet = userWallets.value?.find { it.walletId == userWalletId } ?: raise(SelectWalletError.UnableToSelectUserWallet) selectedUserWalletRepository.set(userWalletId) - selectedUserWallet.value = userWallet + setSelectedUserWallet(userWallet) userWallet } @@ -161,7 +162,7 @@ internal class DefaultUserWalletsListRepository( // update the selectedUserWallet state if it is the only wallet if (userWallets.value?.size == 1) { selectedUserWalletRepository.set(userWallet.walletId) - selectedUserWallet.value = userWallet + setSelectedUserWallet(userWallet) } userWallet @@ -224,21 +225,25 @@ internal class DefaultUserWalletsListRepository( removeHotWalletsFromSDKAndRepos(userWalletIds) - userWallets.update { currentWallets -> - val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } - selectedUserWallet.update { currentSelected -> - if (currentSelected == null) return@update null - val newSelected = updatedWallets?.findAvailableUserWallet( - currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, - ) - if (newSelected == null) { - onAllWalletsDeleted() - } - selectedUserWalletRepository.set(newSelected?.walletId) - newSelected - } - updatedWallets + val currentWallets = userWallets.value + val currentSelected = selectedUserWallet.value + val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } + val newSelected = if (currentSelected == null) { + null + } else { + updatedWallets?.findAvailableUserWallet( + currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, + ) } + + if (currentSelected != null) { + if (newSelected == null) { + onAllWalletsDeleted() + } + selectedUserWalletRepository.set(newSelected?.walletId) + setSelectedUserWallet(newSelected) + } + userWallets.value = updatedWallets } @Suppress("CyclomaticComplexMethod", "LongMethod") @@ -524,6 +529,18 @@ internal class DefaultUserWalletsListRepository( return tangemSdkManagerProvider.invoke().canUseBiometry && isBiometricAuthenticationUsed } + /** + * Writes [userWallet] into [selectedUserWallet] and invokes [userWalletSelectedHandler] when + * the selected [UserWalletId] actually changes. Same-id refreshes stay silent. + */ + private suspend fun setSelectedUserWallet(userWallet: UserWallet?) { + val previousId = selectedUserWallet.value?.walletId + selectedUserWallet.value = userWallet + if (userWallet != null && previousId != userWallet.walletId) { + userWalletSelectedHandler.get().invoke(userWallet) + } + } + private fun updateWallets(block: (List?) -> List?) { userWallets.update { wallets -> val updated = block(wallets) diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt index 3f8d2e44cb..8f93a43df5 100644 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt @@ -2,7 +2,6 @@ package com.tangem.tap.features.demo import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.redux.AppState object DemoHelper { val config = DemoConfig @@ -12,15 +11,4 @@ object DemoHelper { fun isTestDemoCard(scanResponse: ScanResponse): Boolean = config.isTestDemoCardId(scanResponse.card.cardId) fun isDemoCardId(cardId: String): Boolean = config.isDemoCardId(cardId) - - fun tryHandle(appState: () -> AppState?): Boolean { - val scanResponse = getScanResponse(appState) ?: return false - if (!scanResponse.isDemoCard()) return false - - return false - } - - private fun getScanResponse(appState: () -> AppState?): ScanResponse? { - return appState()?.globalState?.scanResponse - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoMiddleware.kt deleted file mode 100644 index 4ddd62e7f3..0000000000 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoMiddleware.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.tap.features.demo - -import com.tangem.domain.demo.models.DemoConfig -import com.tangem.domain.models.scan.ScanResponse -import org.rekotlin.Action - -/** -[REDACTED_AUTHOR] - */ -interface DemoMiddleware { - fun tryHandle(config: DemoConfig, scanResponse: ScanResponse, action: Action): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/demo/Extentions.kt b/app/src/main/java/com/tangem/tap/features/demo/Extentions.kt deleted file mode 100644 index 4805a4263a..0000000000 --- a/app/src/main/java/com/tangem/tap/features/demo/Extentions.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.features.demo - -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.models.scan.ScanResponse - -/** -[REDACTED_AUTHOR] - */ -fun ScanResponse.isDemoCard(): Boolean = DemoHelper.isDemoCardId(card.cardId) -fun CardDTO.isDemoCard(): Boolean = DemoHelper.isDemoCardId(cardId) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt deleted file mode 100644 index 0c647de6c4..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.scan.ScanResponse -import kotlinx.coroutines.CoroutineScope -import org.rekotlin.Action - -@Suppress("BooleanPropertyNaming") -sealed class DetailsAction : Action { - - data class PrepareScreen( - val scanResponse: ScanResponse?, - val initializedAppSettingsState: AppSettingsState, - ) : DetailsAction() - - sealed class AppSettings : DetailsAction() { - data class SwitchPrivacySetting( - val enable: Boolean, - val setting: AppSetting, - ) : AppSettings() { - data object Success : AppSettings() - - data class Failure( - val prevState: Boolean, - val setting: AppSetting, - ) : AppSettings() - } - - data class CheckBiometricsStatus( - val coroutineScope: CoroutineScope, - ) : AppSettings() - - data object EnrollBiometrics : AppSettings() - data class BiometricsStatusChanged( - val isEnrollBiometricsNeeded: Boolean, - ) : AppSettings() - - data class ChangeAppThemeMode( - val appThemeMode: AppThemeMode, - ) : AppSettings() - - data class ChangeBalanceHiding( - val shouldHideBalance: Boolean, - ) : AppSettings() - - data class ChangeAppCurrency( - val currency: AppCurrency, - ) : AppSettings() - - data class Prepare(val state: AppSettingsState) : AppSettings() - } - - data class ChangeAppCurrency(val currency: AppCurrency) : DetailsAction() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt deleted file mode 100644 index 724134783f..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ /dev/null @@ -1,235 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.common.CompletionResult -import com.tangem.common.doOnFailure -import com.tangem.common.doOnSuccess -import com.tangem.core.analytics.Analytics -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.analytics.events.AnalyticsParam -import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware - -@Suppress("MemberNameEqualsClassName") -class DetailsMiddleware { - private val appSettingsMiddleware = AppSettingsMiddleware() - val detailsMiddleware: Middleware = { _, stateProvider -> - { next -> - { action -> - if (!DemoHelper.tryHandle(stateProvider)) { - val detailsState = stateProvider()?.detailsState - if (detailsState != null) { - handleAction(action) - } - } - next(action) - } - } - } - - private fun handleAction(action: Action) { - when (action) { - is DetailsAction.AppSettings -> appSettingsMiddleware.handle(action) - } - } - - class AppSettingsMiddleware { - - private val checkBiometricsStatusJobHolder = JobHolder() - - fun handle(action: DetailsAction.AppSettings) { - when (action) { - is DetailsAction.AppSettings.SwitchPrivacySetting -> { - when (action.setting) { - AppSetting.RequireAccessCode -> toggleRequireAccessCode(enable = action.enable) - AppSetting.BiometricAuthentication -> toggleBiometricsAuthentication(enable = action.enable) - } - } - is DetailsAction.AppSettings.CheckBiometricsStatus -> { - observeBiometricsStatusChanges(action.coroutineScope) - } - is DetailsAction.AppSettings.EnrollBiometrics -> { - enrollBiometrics() - } - is DetailsAction.AppSettings.ChangeAppThemeMode -> { - changeAppThemeMode(action.appThemeMode) - } - is DetailsAction.AppSettings.ChangeBalanceHiding -> { - changeBalanceHiding(action.shouldHideBalance) - } - is DetailsAction.AppSettings.ChangeAppCurrency -> { - store.dispatch(GlobalAction.ChangeAppCurrency(action.currency)) - store.dispatch(DetailsAction.ChangeAppCurrency(action.currency)) - } - is DetailsAction.AppSettings.SwitchPrivacySetting.Success, - is DetailsAction.AppSettings.SwitchPrivacySetting.Failure, - is DetailsAction.AppSettings.BiometricsStatusChanged, - is DetailsAction.AppSettings.Prepare, - -> Unit - } - } - - private fun toggleBiometricsAuthentication(enable: Boolean) { - scope.launch { - val walletsRepository = store.inject(DaggerGraphState::walletsRepository) - - // Nothing to change - if (walletsRepository.useBiometricAuthentication() == enable) { - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - return@launch - } - - if (enable) { - setBiometricLockForAllWallets() - } else { - // Remove all biometric-related data - removeAllBiometricData() - walletsRepository.setRequireAccessCode(value = true) - } - - walletsRepository.setUseBiometricAuthentication(value = enable) - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - } - } - - private fun toggleRequireAccessCode(enable: Boolean) { - scope.launch { - val walletsRepository = store.inject(DaggerGraphState::walletsRepository) - - // Nothing to change - if (walletsRepository.requireAccessCode() == enable) { - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - return@launch - } - - if (enable) { - // Remove all saved access codes - removeAllBiometricSingData() - } - - walletsRepository.setRequireAccessCode(value = enable) - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - } - } - - private suspend fun setBiometricLockForAllWallets() { - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - val userWallets = userWalletsListRepository.userWalletsSync() - userWallets.forEach { wallet -> - userWalletsListRepository.setLock( - userWalletId = wallet.walletId, - lockMethod = LockMethod.Biometric, - changeUnsecured = false, - ) - } - } - - private suspend fun removeAllBiometricData() { - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - userWalletsListRepository.userWalletsSync().forEach { - userWalletsListRepository.removeBiometricLock(it.walletId) - } - removeAllBiometricSingData() - } - - private suspend fun removeAllBiometricSingData() { - deleteSavedAccessCodes() - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - val tangemHotSdk = store.inject(DaggerGraphState::tangemHotSdk) - userWalletsListRepository.userWalletsSync().forEach { wallet -> - if (wallet is UserWallet.Hot) { - userWalletsListRepository.saveWithoutLock( - userWallet = wallet.copy( - hotWalletId = tangemHotSdk.removeBiometryAuthIfPresented(wallet.hotWalletId), - ), - ) - } - } - } - - private fun observeBiometricsStatusChanges(scope: CoroutineScope) { - val needEnrollBiometricsFlow = flow { - do { - val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() - - if (isEnrollBiometricsNeeded != null) { - emit(isEnrollBiometricsNeeded) - } - - delay(timeMillis = 200) - } while (true) - } - - needEnrollBiometricsFlow - .distinctUntilChanged() - .onEach { needEnrollBiometrics -> - store.dispatchWithMain(DetailsAction.AppSettings.BiometricsStatusChanged(needEnrollBiometrics)) - } - .launchIn(scope) - .saveIn(checkBiometricsStatusJobHolder) - } - - private fun enrollBiometrics() { - Analytics.send(Settings.AppSettings.ButtonEnableBiometricAuthentication()) - store.inject(DaggerGraphState::settingsManager).openBiometricSettings() - } - - private fun changeAppThemeMode(appThemeMode: AppThemeMode) { - val repository = store.inject(DaggerGraphState::appThemeModeRepository) - - scope.launch { - repository.changeAppThemeMode(appThemeMode) - } - } - - private fun changeBalanceHiding(hideBalance: Boolean) { - val repository = store.inject(DaggerGraphState::balanceHidingRepository) - - scope.launch { - val newState = repository.getBalanceHidingSettings().copy( - isHidingEnabledInSettings = hideBalance, - isBalanceHidden = false, - ) - - repository.storeBalanceHidingSettings(newState) - } - } - - private suspend fun deleteSavedAccessCodes(): CompletionResult { - return tangemSdkManager.clearSavedUserCodes() - .doOnSuccess { - Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off)) - - store.inject(DaggerGraphState::settingsRepository).setShouldSaveAccessCodes(value = false) - - store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( - isBiometricsRequestPolicy = false, - ) - } - .doOnFailure { error -> - TangemLogger.e("Unable to delete saved access codes", error) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt deleted file mode 100644 index a01428dbfd..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Action - -object DetailsReducer { - fun reduce(action: Action, state: AppState): DetailsState = internalReduce(action, state) -} - -@Suppress("CyclomaticComplexMethod") -private fun internalReduce(action: Action, state: AppState): DetailsState { - if (action !is DetailsAction) return state.detailsState - val detailsState = state.detailsState - return when (action) { - is DetailsAction.PrepareScreen -> { - handlePrepareScreen(action) - } - is DetailsAction.AppSettings -> { - handlePrivacyAction(action, detailsState) - } - is DetailsAction.ChangeAppCurrency -> detailsState.copy( - appSettingsState = detailsState.appSettingsState.copy( - selectedAppCurrency = action.currency, - ), - ) - } -} - -private fun handlePrepareScreen(action: DetailsAction.PrepareScreen): DetailsState { - return DetailsState( - scanResponse = action.scanResponse, - appSettingsState = action.initializedAppSettingsState, - ) -} - -@Suppress("LongMethod", "CyclomaticComplexMethod") -private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: DetailsState): DetailsState { - return when (action) { - is DetailsAction.AppSettings.SwitchPrivacySetting -> state.copy( - appSettingsState = when (action.setting) { - AppSetting.RequireAccessCode -> state.appSettingsState.copy( - isInProgress = true, - requireAccessCode = action.enable, - ) - AppSetting.BiometricAuthentication -> state.appSettingsState.copy( - isInProgress = true, - useBiometricAuthentication = action.enable, - ) - }, - ) - is DetailsAction.AppSettings.SwitchPrivacySetting.Success -> state.copy( - appSettingsState = state.appSettingsState.copy( - isInProgress = false, - ), - ) - is DetailsAction.AppSettings.SwitchPrivacySetting.Failure -> state.copy( - appSettingsState = when (action.setting) { - AppSetting.RequireAccessCode -> state.appSettingsState.copy( - isInProgress = false, - requireAccessCode = action.prevState, - ) - AppSetting.BiometricAuthentication -> state.appSettingsState.copy( - isInProgress = false, - needEnrollBiometrics = action.prevState, - ) - }, - ) - is DetailsAction.AppSettings.BiometricsStatusChanged -> state.copy( - appSettingsState = state.appSettingsState.copy( - needEnrollBiometrics = action.isEnrollBiometricsNeeded, - ), - ) - is DetailsAction.AppSettings.ChangeAppThemeMode -> state.copy( - appSettingsState = state.appSettingsState.copy( - selectedThemeMode = action.appThemeMode, - ), - ) - is DetailsAction.AppSettings.ChangeAppCurrency -> state.copy( - appSettingsState = state.appSettingsState.copy( - selectedAppCurrency = action.currency, - ), - ) - is DetailsAction.AppSettings.ChangeBalanceHiding -> state.copy( - appSettingsState = state.appSettingsState.copy( - isHidingEnabled = action.shouldHideBalance, - ), - ) - // state should be copied to avoid concurrent modifications from different sources - is DetailsAction.AppSettings.Prepare -> state.copy( - appSettingsState = state.appSettingsState.copy( - isHidingEnabled = action.state.isHidingEnabled, - selectedAppCurrency = action.state.selectedAppCurrency, - selectedThemeMode = action.state.selectedThemeMode, - useBiometricAuthentication = action.state.useBiometricAuthentication, - requireAccessCode = action.state.requireAccessCode, - hasSecuredWallets = action.state.hasSecuredWallets, - ), - ) - is DetailsAction.AppSettings.EnrollBiometrics, - is DetailsAction.AppSettings.CheckBiometricsStatus, - -> state - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt deleted file mode 100644 index 83cf685304..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.scan.ScanResponse -import org.rekotlin.StateType - -data class DetailsState( - @Deprecated("Delete after onboarding refactoring") - val scanResponse: ScanResponse? = null, - val appSettingsState: AppSettingsState = AppSettingsState(), -) : StateType - -@Suppress("BooleanPropertyNaming") -data class AppSettingsState( - val requireAccessCode: Boolean = false, - val useBiometricAuthentication: Boolean = false, - val needEnrollBiometrics: Boolean = false, - val hasSecuredWallets: Boolean = false, - val isHidingEnabled: Boolean = false, - val isInProgress: Boolean = false, - val selectedAppCurrency: AppCurrency = AppCurrency.Default, - val selectedThemeMode: AppThemeMode = AppThemeMode.DEFAULT, -) - -enum class SecurityOption { LongTap, PassCode, AccessCode } - -enum class AppSetting { - RequireAccessCode, BiometricAuthentication, -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/SecurityOption.kt b/app/src/main/java/com/tangem/tap/features/details/redux/SecurityOption.kt new file mode 100644 index 0000000000..e55259df3b --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/details/redux/SecurityOption.kt @@ -0,0 +1,3 @@ +package com.tangem.tap.features.details.redux + +enum class SecurityOption { LongTap, PassCode, AccessCode } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt index 106fad8b50..20d7536526 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt @@ -2,71 +2,59 @@ package com.tangem.tap.features.details.ui.appsettings import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.wallet.R -import kotlinx.collections.immutable.toImmutableList internal class AppSettingsDialogsFactory { - fun createThemeModeSelectorDialog( - selectedModeIndex: Int, - onSelect: (AppThemeMode) -> Unit, - onDismiss: () -> Unit, - ): Dialog.Selector { - val modes = AppThemeMode.available - - return Dialog.Selector( - title = resourceReference(R.string.app_settings_theme_selector_title), - selectedItemIndex = selectedModeIndex, - items = modes.map { mode -> - resourceReference( - id = when (mode) { - AppThemeMode.FORCE_DARK -> R.string.app_settings_theme_mode_dark - AppThemeMode.FORCE_LIGHT -> R.string.app_settings_theme_mode_light - AppThemeMode.FOLLOW_SYSTEM -> R.string.app_settings_theme_mode_system - }, - ) - }.toImmutableList(), - onSelect = { index -> - val mode = AppThemeMode.available[index] - - onSelect(mode) - }, - onDismiss = onDismiss, - ) - } - - fun createDisableBiometricAuthenticationAlert(onDisable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert { - return Dialog.Alert( + fun createDisableBiometricAuthenticationAlert(onDisable: () -> Unit): DialogMessage { + return DialogMessage( title = resourceReference(R.string.common_attention), - description = resourceReference( + message = resourceReference( R.string.app_settings_off_biometrics_alert_message, wrappedList(resourceReference(R.string.common_biometrics)), ), - confirmText = resourceReference(R.string.common_disable), - onConfirm = onDisable, - onDismiss = onDismiss, + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_disable), + isWarning = true, + onClick = onDisable, + ) + }, + secondActionBuilder = { cancelAction() }, ) } - fun createEnableRequireAccessCodeAlert(onEnable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert { - return Dialog.Alert( + fun createEnableRequireAccessCodeAlert(onEnable: () -> Unit): DialogMessage { + return DialogMessage( title = resourceReference(R.string.common_attention), - description = resourceReference(R.string.app_settings_on_require_access_code_alert_message), - confirmText = resourceReference(R.string.common_enable), - onConfirm = { onEnable() }, - onDismiss = onDismiss, + message = resourceReference(R.string.app_settings_on_require_access_code_alert_message), + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_enable), + onClick = onEnable, + ) + }, + secondActionBuilder = { cancelAction() }, ) } - fun createDisableRequireAccessCodeAlert(onDisable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert { - return Dialog.Alert( + fun createDisableRequireAccessCodeAlert(onDisable: () -> Unit): DialogMessage { + return DialogMessage( title = resourceReference(R.string.common_attention), - description = resourceReference(R.string.app_settings_off_require_access_code_alert_message), - confirmText = resourceReference(R.string.common_disable), - onConfirm = { onDisable() }, - onDismiss = onDismiss, + message = resourceReference(R.string.app_settings_off_require_access_code_alert_message), + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_disable), + isWarning = true, + onClick = onDisable, + ) + }, + secondActionBuilder = { cancelAction() }, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt index d17e6b23b6..8ffb314316 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview @@ -42,13 +40,6 @@ internal fun AppSettingsScreen(state: AppSettingsScreenState, onBackClick: () -> @Composable private fun AppSettings(state: AppSettingsScreenState.Content) { - val dialog by rememberUpdatedState(newValue = state.dialog) - when (val safeDialog = dialog) { - is AppSettingsScreenState.Dialog.Alert -> SettingsAlertDialog(dialog = safeDialog) - is AppSettingsScreenState.Dialog.Selector -> SettingsSelectorDialog(dialog = safeDialog) - null -> Unit - } - val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } LazyColumn( @@ -102,12 +93,7 @@ private class AppSettingsScreenStateProvider : CollectionPreviewParameterProvide itemsFactory.createSelectThemeModeButton(AppThemeMode.DEFAULT, {}), ) - add( - AppSettingsScreenState.Content( - items = items, - dialog = null, - ), - ) + add(AppSettingsScreenState.Content(items = items)) }, ) // endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt index f73524e227..615d6248ce 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt @@ -10,10 +10,7 @@ internal sealed class AppSettingsScreenState { object Loading : AppSettingsScreenState() - data class Content( - val items: ImmutableList, - val dialog: Dialog?, - ) : AppSettingsScreenState() + data class Content(val items: ImmutableList) : AppSettingsScreenState() @Immutable sealed class Item { @@ -46,26 +43,4 @@ internal sealed class AppSettingsScreenState { val onClick: () -> Unit, ) : Item() } - - @Immutable - sealed class Dialog { - - abstract val onDismiss: () -> Unit - - data class Alert( - val title: TextReference, - val description: TextReference, - val confirmText: TextReference, - val onConfirm: () -> Unit, - override val onDismiss: () -> Unit, - ) : Dialog() - - data class Selector( - val title: TextReference, - val selectedItemIndex: Int, - val items: ImmutableList, - val onSelect: (Int) -> Unit, - override val onDismiss: () -> Unit, - ) : Dialog() - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt index a92b632003..116aebd3d5 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt @@ -4,42 +4,56 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.essenty.lifecycle.doOnResume -import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent +import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsDialogConfig import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("UnusedPrivateMember") internal class DefaultAppSettingsComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: Unit, + @Suppress("UnusedPrivateMember") @Assisted params: Unit, ) : AppSettingsComponent, AppComponentContext by appComponentContext { private val model: AppSettingsModel = getOrCreateModel() - init { + private val dialogSlot = childSlot( + source = model.dialogNavigation, + serializer = AppSettingsDialogConfig.serializer(), + handleBackButton = true, + childFactory = { config, _ -> config }, + ) + init { doOnResume { model.onResume() } } @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val dialog by dialogSlot.subscribeAsState() AppSettingsScreen( modifier = modifier, state = state, - onBackClick = { - store.dispatchNavigationAction(AppRouter::pop) - }, + onBackClick = model::onBackClick, ) + + dialog.child?.instance?.let { config -> + when (config) { + is AppSettingsDialogConfig.ThemeModeSelector -> SettingsSelectorDialog( + config = config, + onSelect = model::onThemeModeSelected, + onDismiss = model::dismissDialog, + ) + } + } } @AssistedFactory diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/SettingsSelectorDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/SettingsSelectorDialog.kt new file mode 100644 index 0000000000..cf9f888c0e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/SettingsSelectorDialog.kt @@ -0,0 +1,38 @@ +package com.tangem.tap.features.details.ui.appsettings + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.components.SelectorDialog +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.domain.apptheme.model.AppThemeMode +import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsDialogConfig +import com.tangem.wallet.R +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun SettingsSelectorDialog( + config: AppSettingsDialogConfig.ThemeModeSelector, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val modes = AppThemeMode.available + SelectorDialog( + title = stringResourceSafe(R.string.app_settings_theme_selector_title), + selectedItemIndex = config.selectedModeIndex, + items = modes.map { mode -> + stringResourceSafe( + id = when (mode) { + AppThemeMode.FORCE_DARK -> R.string.app_settings_theme_mode_dark + AppThemeMode.FORCE_LIGHT -> R.string.app_settings_theme_mode_light + AppThemeMode.FOLLOW_SYSTEM -> R.string.app_settings_theme_mode_system + }, + ) + }.toImmutableList(), + confirmButton = DialogButtonUM( + title = stringResourceSafe(R.string.common_cancel), + onClick = onDismiss, + ), + onSelect = onSelect, + onDismissDialog = onDismiss, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt deleted file mode 100644 index ff7916fd68..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.tangem.tap.features.details.ui.appsettings.components - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButtonUM -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory -import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog -import com.tangem.wallet.R - -@Composable -internal fun SettingsAlertDialog(dialog: Dialog.Alert) { - BasicDialog( - title = dialog.title.resolveReference(), - message = dialog.description.resolveReference(), - isDismissable = false, - confirmButton = DialogButtonUM( - title = dialog.confirmText.resolveReference(), - isWarning = true, - onClick = dialog.onConfirm, - ), - dismissButton = DialogButtonUM( - title = stringResourceSafe(id = R.string.common_cancel), - onClick = dialog.onDismiss, - ), - onDismissDialog = dialog.onDismiss, - ) -} - -// region Preview -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun AlertDialogPreview(@PreviewParameter(AlertDialogProvider::class) dialog: Dialog.Alert) { - TangemThemePreview { - SettingsAlertDialog(dialog = dialog) - } -} - -private class AlertDialogProvider : CollectionPreviewParameterProvider( - collection = buildList { - val dialogsFactory = AppSettingsDialogsFactory() - - add(dialogsFactory.createThemeModeSelectorDialog(selectedModeIndex = 0, onSelect = {}, onDismiss = {})) - add(dialogsFactory.createDisableBiometricAuthenticationAlert(onDisable = {}, onDismiss = {})) - }, -) -// endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt deleted file mode 100644 index 3dba72ed5d..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tangem.tap.features.details.ui.appsettings.components - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.DialogButtonUM -import com.tangem.core.ui.components.SelectorDialog -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory -import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog -import com.tangem.wallet.R -import kotlinx.collections.immutable.toImmutableList - -@Composable -internal fun SettingsSelectorDialog(dialog: Dialog.Selector) { - SelectorDialog( - title = dialog.title.resolveReference(), - selectedItemIndex = dialog.selectedItemIndex, - items = dialog.items.map { it.resolveReference() }.toImmutableList(), - confirmButton = DialogButtonUM( - title = stringResourceSafe(R.string.common_cancel), - onClick = dialog.onDismiss, - ), - onSelect = dialog.onSelect, - onDismissDialog = dialog.onDismiss, - ) -} - -// region Preview -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun SettingsSelectorDialogPreview(@PreviewParameter(DialogProvider::class) param: Dialog.Selector) { - TangemThemePreview { - SettingsSelectorDialog(param) - } -} - -private class DialogProvider : CollectionPreviewParameterProvider( - collection = listOf( - AppSettingsDialogsFactory().createThemeModeSelectorDialog( - selectedModeIndex = 0, - onSelect = {}, - onDismiss = {}, - ), - ), -) -// endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsDialogConfig.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsDialogConfig.kt new file mode 100644 index 0000000000..4a53c9dc3e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsDialogConfig.kt @@ -0,0 +1,10 @@ +package com.tangem.tap.features.details.ui.appsettings.model + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface AppSettingsDialogConfig { + + @Serializable + data class ThemeModeSelector(val selectedModeIndex: Int) : AppSettingsDialogConfig +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index 3f7613c86c..181ae202e1 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt @@ -1,11 +1,18 @@ package com.tangem.tap.features.details.ui.appsettings.model import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.doOnFailure +import com.tangem.common.doOnSuccess import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.appcurrency.model.AppCurrency @@ -13,113 +20,138 @@ import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository +import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.hot.sdk.TangemHotSdk +import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.features.details.redux.AppSetting -import com.tangem.tap.features.details.redux.AppSettingsState -import com.tangem.tap.features.details.redux.DetailsAction -import com.tangem.tap.features.details.redux.DetailsState import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState import com.tangem.tap.features.details.ui.appsettings.analytics.AppSettingsItemsAnalyticsSender -import com.tangem.tap.scope -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.extensions.addIf +import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import org.rekotlin.StoreSubscriber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class AppSettingsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val appCurrencyRepository: AppCurrencyRepository, + appCurrencyRepository: AppCurrencyRepository, private val walletsRepository: WalletsRepository, private val userWalletsListRepository: UserWalletsListRepository, private val balanceHidingRepository: BalanceHidingRepository, private val analyticsEventHandler: AnalyticsEventHandler, private val appThemeModeRepository: AppThemeModeRepository, private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender, + private val tangemSdkManager: TangemSdkManager, + private val settingsManager: SettingsManager, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val tangemHotSdk: TangemHotSdk, + private val router: Router, private val uiMessageSender: UiMessageSender, -) : Model(), StoreSubscriber { +) : Model() { private val itemsFactory = AppSettingsItemsFactory() private val dialogsFactory = AppSettingsDialogsFactory() - private val appCurrencyUpdatesJobHolder = JobHolder() + val dialogNavigation: SlotNavigation = SlotNavigation() - private val _uiState: MutableStateFlow = MutableStateFlow( - value = AppSettingsScreenState.Loading, - ) - val uiState: StateFlow = _uiState + private val localState = MutableStateFlow(LocalState()) + private val biometricsStatusJobHolder = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow(value = AppSettingsScreenState.Loading) init { - bootstrapAppCurrencyUpdates() - bootstrapBiometricsUpdates() + bootstrapLocalState() + + combine( + flow = appCurrencyRepository.getSelectedAppCurrency().distinctUntilChanged(), + flow2 = appThemeModeRepository.getAppThemeMode(), + flow3 = balanceHidingRepository.getBalanceHidingSettingsFlow(), + flow4 = localState, + ) { currency, themeMode, hidingSettings, local -> + AppSettingsState( + appCurrency = currency, + themeMode = themeMode, + isHidingEnabled = hidingSettings.isHidingEnabledInSettings, + local = local, + ) + } + .onEach { state -> + val items = buildItems(state) + uiState.update { prevState -> + when (prevState) { + is AppSettingsScreenState.Content -> prevState.copy(items = items) + is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content(items = items) + } + } + } + .launchIn(modelScope) - subscribeToStoreChanges() sendItemsAnalytics() } - override fun newState(state: DetailsState) { - val items = buildItems(state.appSettingsState) - - _uiState.update { prevState -> - when (prevState) { - is AppSettingsScreenState.Content -> prevState.copy( - items = items, - ) - is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content( - items = items, - dialog = null, - ) - } - } - } - fun onResume() { - store.dispatch(DetailsAction.AppSettings.CheckBiometricsStatus(modelScope)) + observeBiometricsStatusChanges() } - override fun onDestroy() { - super.onDestroy() - store.unsubscribe(subscriber = this) + private fun observeBiometricsStatusChanges() { + flow { + do { + val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() + if (isEnrollBiometricsNeeded != null) { + emit(isEnrollBiometricsNeeded) + } + delay(timeMillis = 200) + } while (true) + } + .flowOn(dispatchers.default) + .distinctUntilChanged() + .onEach { isEnrollBiometricsNeeded -> + localState.update { it.copy(isEnrollBiometricsNeeded = isEnrollBiometricsNeeded) } + } + .launchIn(modelScope) + .saveIn(biometricsStatusJobHolder) } private fun buildItems(state: AppSettingsState): ImmutableList { val items = buildList { addIf( - condition = state.needEnrollBiometrics, + condition = state.local.isEnrollBiometricsNeeded, element = itemsFactory.createEnrollBiometricsCard(onClick = ::enrollBiometrics), ) add( itemsFactory.createSelectAppCurrencyButton( - currentAppCurrencyName = state.selectedAppCurrency.name, + currentAppCurrencyName = state.appCurrency.name, onClick = ::showAppCurrencySelector, ), ) - val canUseBiometrics = - !state.needEnrollBiometrics && !state.isInProgress && state.hasSecuredWallets + val canUseBiometrics = with(state.local) { + !isEnrollBiometricsNeeded && !isInProgress && hasSecuredWallets + } add( itemsFactory.createUseBiometricsSwitch( - isChecked = state.useBiometricAuthentication, + isChecked = state.local.isBiometricAuthenticationUsed, isEnabled = canUseBiometrics, onCheckedChange = ::onBiometricAuthenticationToggled, onDisabledClick = ::onBiometricAuthenticationDisabledClicked, @@ -128,8 +160,8 @@ internal class AppSettingsModel @Inject constructor( add( itemsFactory.createRequireAccessCodeSwitch( - isChecked = state.requireAccessCode || !state.useBiometricAuthentication, - isEnabled = canUseBiometrics && state.useBiometricAuthentication, + isChecked = state.local.isAccessCodeRequired || !state.local.isBiometricAuthenticationUsed, + isEnabled = canUseBiometrics && state.local.isBiometricAuthenticationUsed, onCheckedChange = ::onRequireAccessCodeToggled, ), ) @@ -144,8 +176,8 @@ internal class AppSettingsModel @Inject constructor( add( itemsFactory.createSelectThemeModeButton( - currentThemeMode = state.selectedThemeMode, - onClick = { showThemeModeSelector(state.selectedThemeMode) }, + currentThemeMode = state.themeMode, + onClick = { showThemeModeSelector(state.themeMode) }, ), ) } @@ -154,31 +186,35 @@ internal class AppSettingsModel @Inject constructor( } private fun enrollBiometrics() { - store.dispatchOnMain(DetailsAction.AppSettings.EnrollBiometrics) + analyticsEventHandler.send(Settings.AppSettings.ButtonEnableBiometricAuthentication()) + settingsManager.openBiometricSettings() + } + + fun onBackClick() { + router.pop() } private fun showAppCurrencySelector() { - store.dispatchNavigationAction { push(AppRoute.AppCurrencySelector) } + router.push(AppRoute.AppCurrencySelector) } private fun showThemeModeSelector(selectedMode: AppThemeMode) { - updateContentState { - copy( - dialog = dialogsFactory.createThemeModeSelectorDialog( - selectedModeIndex = selectedMode.ordinal, - onSelect = { mode -> - analyticsEventHandler.send( - event = Settings.AppSettings.ThemeSwitched( - theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode), - ), - ) - store.dispatchOnMain(DetailsAction.AppSettings.ChangeAppThemeMode(mode)) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ), - ) - } + dialogNavigation.activate(AppSettingsDialogConfig.ThemeModeSelector(selectedMode.ordinal)) + } + + fun onThemeModeSelected(index: Int) { + val mode = AppThemeMode.available[index] + analyticsEventHandler.send( + event = Settings.AppSettings.ThemeSwitched( + theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode), + ), + ) + changeAppThemeMode(mode) + dialogNavigation.dismiss() + } + + fun dismissDialog() { + dialogNavigation.dismiss() } private fun onBiometricAuthenticationToggled(isChecked: Boolean) { @@ -186,19 +222,13 @@ internal class AppSettingsModel @Inject constructor( // val param = AnalyticsParam.OnOffState(isChecked) // analyticsEventHandler.send(Settings.AppSettings.BiometricAuthenticationChanged(param)) if (isChecked) { - onSettingsToggled(AppSetting.BiometricAuthentication, enable = true) + toggleBiometricsAuthentication(enable = true) } else { - updateContentState { - copy( - dialog = dialogsFactory.createDisableBiometricAuthenticationAlert( - onDisable = { - onSettingsToggled(AppSetting.BiometricAuthentication, enable = false) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ), - ) - } + uiMessageSender.send( + dialogsFactory.createDisableBiometricAuthenticationAlert( + onDisable = { toggleBiometricsAuthentication(enable = false) }, + ), + ) } } @@ -210,74 +240,136 @@ internal class AppSettingsModel @Inject constructor( // TODO : Uncomment and implement analytics event when ready // val param = AnalyticsParam.OnOffState(isChecked) // analyticsEventHandler.send(Settings.AppSettings.RequireAccessCodeChanged(param)) - updateContentState { - copy( - dialog = if (isChecked) { - dialogsFactory.createEnableRequireAccessCodeAlert( - onEnable = { - onSettingsToggled(AppSetting.RequireAccessCode, enable = true) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ) - } else { - dialogsFactory.createDisableRequireAccessCodeAlert( - onDisable = { - onSettingsToggled(AppSetting.RequireAccessCode, enable = false) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ) - }, + if (isChecked) { + uiMessageSender.send( + dialogsFactory.createEnableRequireAccessCodeAlert( + onEnable = { toggleRequireAccessCode(enable = true) }, + ), + ) + } else { + uiMessageSender.send( + dialogsFactory.createDisableRequireAccessCodeAlert( + onDisable = { toggleRequireAccessCode(enable = false) }, + ), ) } } - private fun onSettingsToggled(setting: AppSetting, enable: Boolean) { - store.dispatch(DetailsAction.AppSettings.SwitchPrivacySetting(enable = enable, setting = setting)) + private fun toggleBiometricsAuthentication(enable: Boolean) { + localState.update { it.copy(isBiometricAuthenticationUsed = enable, isInProgress = true) } + + modelScope.launch { + // Nothing to change + if (walletsRepository.useBiometricAuthentication() == enable) { + localState.update { it.copy(isInProgress = false) } + return@launch + } + + if (enable) { + setBiometricLockForAllWallets() + } else { + removeAllBiometricData() + walletsRepository.setRequireAccessCode(value = true) + localState.update { it.copy(isAccessCodeRequired = true) } + } + + walletsRepository.setUseBiometricAuthentication(value = enable) + localState.update { it.copy(isInProgress = false) } + } + } + + private fun toggleRequireAccessCode(enable: Boolean) { + localState.update { it.copy(isAccessCodeRequired = enable, isInProgress = true) } + + modelScope.launch { + // Nothing to change + if (walletsRepository.requireAccessCode() == enable) { + localState.update { it.copy(isInProgress = false) } + return@launch + } + + if (enable) { + removeAllBiometricSingData(userWalletsListRepository.userWalletsSync()) + } + + walletsRepository.setRequireAccessCode(value = enable) + localState.update { it.copy(isInProgress = false) } + } + } + + private suspend fun setBiometricLockForAllWallets() { + val userWallets = userWalletsListRepository.userWalletsSync() + userWallets.forEach { wallet -> + userWalletsListRepository.setLock( + userWalletId = wallet.walletId, + lockMethod = UserWalletsListRepository.LockMethod.Biometric, + changeUnsecured = false, + ) + } + } + + private suspend fun removeAllBiometricData() { + val userWallets = userWalletsListRepository.userWalletsSync() + userWallets.forEach { + userWalletsListRepository.removeBiometricLock(it.walletId) + } + removeAllBiometricSingData(userWallets) + } + + private suspend fun removeAllBiometricSingData(userWallets: List) { + deleteSavedAccessCodes() + userWallets.forEach { wallet -> + if (wallet is UserWallet.Hot) { + userWalletsListRepository.saveWithoutLock( + userWallet = wallet.copy( + hotWalletId = tangemHotSdk.removeBiometryAuthIfPresented(wallet.hotWalletId), + ), + ) + } + } + } + + private suspend fun deleteSavedAccessCodes() { + tangemSdkManager.clearSavedUserCodes() + .doOnSuccess { + analyticsEventHandler.send( + Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off), + ) + settingsRepository.setShouldSaveAccessCodes(value = false) + cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) + } + .doOnFailure { error -> + TangemLogger.e("Unable to delete saved access codes", error) + } } private fun onFlipToHideBalanceToggled(enable: Boolean) { val param = AnalyticsParam.OnOffState(enable) analyticsEventHandler.send(Settings.AppSettings.HideBalanceChanged(param)) - store.dispatch(DetailsAction.AppSettings.ChangeBalanceHiding(shouldHideBalance = enable)) + modelScope.launch { + val settings = balanceHidingRepository.getBalanceHidingSettings().copy( + isHidingEnabledInSettings = enable, + isBalanceHidden = false, + ) + balanceHidingRepository.storeBalanceHidingSettings(settings) + } } - private fun dismissDialog() { - updateContentState { copy(dialog = null) } + private fun changeAppThemeMode(mode: AppThemeMode) { + modelScope.launch { + appThemeModeRepository.changeAppThemeMode(mode) + } } - private fun bootstrapAppCurrencyUpdates() { - appCurrencyRepository - .getSelectedAppCurrency() - .onEach { appCurrency -> - if (appCurrency.code == store.state.globalState.appCurrency.code) return@onEach - - store.dispatchWithMain(DetailsAction.AppSettings.ChangeAppCurrency(appCurrency)) - } - .launchIn(scope) - .saveIn(appCurrencyUpdatesJobHolder) - } - - private fun bootstrapBiometricsUpdates() = modelScope.launch { - val state = AppSettingsState( - useBiometricAuthentication = walletsRepository.useBiometricAuthentication(), - requireAccessCode = walletsRepository.requireAccessCode(), - isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings, - selectedAppCurrency = appCurrencyRepository.getSelectedAppCurrency().firstOrNull() ?: AppCurrency.Default, - selectedThemeMode = appThemeModeRepository.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT, - hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), - ) - - store.dispatchWithMain(DetailsAction.AppSettings.Prepare(state)) - } - - private fun subscribeToStoreChanges() { - store.subscribe(subscriber = this) { state -> - state.skipRepeats { oldState, newState -> - oldState.detailsState == newState.detailsState - }.select { it.detailsState } + private fun bootstrapLocalState() = modelScope.launch { + localState.update { state -> + state.copy( + hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), + isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, + isBiometricAuthenticationUsed = walletsRepository.useBiometricAuthentication(), + isAccessCodeRequired = walletsRepository.requireAccessCode(), + ) } } @@ -286,15 +378,21 @@ internal class AppSettingsModel @Inject constructor( .filterIsInstance() .distinctUntilChangedBy(AppSettingsScreenState.Content::items) .onEach { appSettingsItemsAnalyticsSender.send(it.items) } - .launchIn(scope) + .launchIn(modelScope) } - private fun updateContentState(block: AppSettingsScreenState.Content.() -> AppSettingsScreenState.Content) { - _uiState.update { prevState -> - when (prevState) { - is AppSettingsScreenState.Content -> block(prevState) - is AppSettingsScreenState.Loading -> prevState - } - } - } + private data class LocalState( + val hasSecuredWallets: Boolean = false, + val isEnrollBiometricsNeeded: Boolean = false, + val isBiometricAuthenticationUsed: Boolean = false, + val isAccessCodeRequired: Boolean = false, + val isInProgress: Boolean = false, + ) + + private data class AppSettingsState( + val themeMode: AppThemeMode = AppThemeMode.DEFAULT, + val isHidingEnabled: Boolean = false, + val appCurrency: AppCurrency = AppCurrency.Default, + val local: LocalState = LocalState(), + ) } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt index fd75548292..8384a3bf29 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt @@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.cardsettings.coderecovery.api.AccessCodeRecoveryComponent import com.tangem.tap.features.details.ui.cardsettings.coderecovery.model.AccessCodeRecoveryModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -19,6 +17,7 @@ import dagger.assisted.AssistedInject internal class DefaultAccessCodeRecoveryComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: Unit, + private val appRouter: AppRouter, ) : AccessCodeRecoveryComponent, AppComponentContext by appComponentContext { private val model: AccessCodeRecoveryModel = getOrCreateModel() @@ -29,7 +28,7 @@ internal class DefaultAccessCodeRecoveryComponent @AssistedInject constructor( AccessCodeRecoveryScreen( state = state, - onBackClick = { store.dispatchNavigationAction(AppRouter::pop) }, + onBackClick = { appRouter.pop() }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt index ac083db4c6..56092cb9b6 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt @@ -10,11 +10,9 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.cardsettings.coderecovery.AccessCodeRecoveryScreenState import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.isAccessCodeRecoveryEnabled -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -27,6 +25,7 @@ internal class AccessCodeRecoveryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val cardSettingsInteractor: CardSettingsInteractor, + private val appRouter: AppRouter, ) : Model() { private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value @@ -73,7 +72,7 @@ internal class AccessCodeRecoveryModel @Inject constructor( ) } - store.dispatchNavigationAction(AppRouter::pop) + appRouter.pop() } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt index ecce5aaf75..3178d30e40 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt @@ -27,13 +27,11 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.cardsettings.CardInfo import com.tangem.tap.features.details.ui.cardsettings.CardSettingsScreenState import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.* -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addIf import com.tangem.utils.logging.TangemLogger @@ -56,6 +54,7 @@ internal class CardSettingsModel @Inject constructor( private val settingsRepository: SettingsRepository, private val onboardingRepository: OnboardingRepository, private val uiMessageSender: UiMessageSender, + private val appRouter: AppRouter, ) : Model() { private val params = paramsContainer.require() @@ -188,12 +187,10 @@ internal class CardSettingsModel @Inject constructor( } is CardInfo.SecurityMode -> { Analytics.send(Settings.CardSettings.ButtonChangeSecurityMode()) - store.dispatchNavigationAction { - push(route = AppRoute.DetailsSecurity(userWalletId)) - } + appRouter.push(route = AppRoute.DetailsSecurity(userWalletId)) } is CardInfo.AccessCodeRecovery -> { - store.dispatchNavigationAction { push(AppRoute.AccessCodeRecovery) } + appRouter.push(AppRoute.AccessCodeRecovery) } else -> {} } @@ -205,30 +202,26 @@ internal class CardSettingsModel @Inject constructor( } if (scanResponse.cardTypesResolver.isTangemTwins()) { - store.dispatchNavigationAction { - push( - AppRoute.Onboarding( - scanResponse = scanResponse, - mode = AppRoute.Onboarding.Mode.RecreateWalletTwin, - ), - ) - } + appRouter.push( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.RecreateWalletTwin, + ), + ) } else { val card = scanResponse.card modelScope.launch { val hasTangemPay = onboardingRepository.hasTangemPayInWallet(userWalletId).getOrNull() == true - store.dispatchNavigationAction { - push( - route = AppRoute.ResetToFactory( - userWalletId = userWalletId, - cardId = card.cardId, - isActiveBackupStatus = card.backupStatus?.isActive == true, - backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, - hasTangemPay = hasTangemPay, - ), - ) - } + appRouter.push( + route = AppRoute.ResetToFactory( + userWalletId = userWalletId, + cardId = card.cardId, + isActiveBackupStatus = card.backupStatus?.isActive == true, + backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, + hasTangemPay = hasTangemPay, + ), + ) } } } @@ -252,6 +245,6 @@ internal class CardSettingsModel @Inject constructor( private fun onBackClick() { cardSettingsInteractor.clear() - store.dispatchNavigationAction(AppRouter::pop) + appRouter.pop() } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt index 16000cb7bb..92fcf9b4b9 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt @@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent import com.tangem.tap.features.details.ui.resetcard.model.ResetCardModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,6 +16,7 @@ import dagger.assisted.AssistedInject internal class DefaultResetCardComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: ResetCardComponent.Params, + private val appRouter: AppRouter, ) : ResetCardComponent, AppComponentContext by appComponentContext { private val model: ResetCardModel = getOrCreateModel(params) @@ -29,7 +28,7 @@ internal class DefaultResetCardComponent @AssistedInject constructor( ResetCardScreen( modifier = modifier, state = state, - onBackClick = { store.dispatchNavigationAction(AppRouter::pop) }, + onBackClick = { appRouter.pop() }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt index 10ec357e6b..2f301e8206 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt @@ -3,6 +3,7 @@ package com.tangem.tap.features.details.ui.resetcard.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.routing.utils.popTo import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -18,14 +19,11 @@ import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.getResetToFactoryDescription import com.tangem.tap.features.details.ui.resetcard.ResetCardDialog import com.tangem.tap.features.details.ui.resetcard.ResetCardScreenState import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import com.tangem.utils.logging.TangemLogger @@ -51,6 +49,7 @@ internal class ResetCardModel @Inject constructor( private val deleteWalletUseCase: DeleteWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val cardSettingsInteractor: CardSettingsInteractor, + private val appRouter: AppRouter, ) : Model() { private val params = paramsContainer.require() @@ -189,23 +188,18 @@ internal class ResetCardModel @Inject constructor( modelScope.launch { resetCardUseCase(cardId = primaryCardId, params = currentUserCodeParams).onRight { deleteSavedAccessCodesUseCase(cardId = primaryCardId) - val hasUserWallets = deleteWalletUseCase(userWalletId = currentUserWalletId).getOrElse { error -> + deleteWalletUseCase(userWalletId = currentUserWalletId).getOrElse { error -> TangemLogger.e("Unable to delete user wallet: $error") return@launch } - if (hasUserWallets) { - val newSelectedWallet = getSelectedWalletSyncUseCase().getOrElse { error -> - error("Failed to get selected wallet: $error") - } - - store.onUserWalletSelected(newSelectedWallet) - } - delay(DELAY_SDK_DIALOG_CLOSE) checkRemainingBackupCards() } + .onLeft { + TangemLogger.e("Failed to reset card: $it") + } } } @@ -270,9 +264,9 @@ internal class ResetCardModel @Inject constructor( val newSelectedWallet = getSelectedWalletSyncUseCase.invoke().getOrNull() if (newSelectedWallet != null) { - store.dispatchNavigationAction { popTo() } + appRouter.popTo() } else { - store.dispatchNavigationAction { replaceAll(AppRoute.Home()) } + appRouter.replaceAll(AppRoute.Home()) } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt index 151b4c123d..98ae5b6c16 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt @@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent import com.tangem.tap.features.details.ui.securitymode.model.SecurityModeModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,6 +16,7 @@ import dagger.assisted.AssistedInject internal class DefaultSecurityModeComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: SecurityModeComponent.Params, + private val appRouter: AppRouter, ) : SecurityModeComponent, AppComponentContext by appComponentContext { private val model: SecurityModeModel = getOrCreateModel(params) @@ -29,7 +28,7 @@ internal class DefaultSecurityModeComponent @AssistedInject constructor( SecurityModeScreen( modifier = modifier, state = state, - onBackClick = { store.dispatchNavigationAction(AppRouter::pop) }, + onBackClick = { appRouter.pop() }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt index 36fdf29787..b6682fe212 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt @@ -13,13 +13,11 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.redux.SecurityOption import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.getAllowedSecurityOptions import com.tangem.tap.features.details.ui.common.utils.getCurrentSecurityOption import com.tangem.tap.features.details.ui.securitymode.SecurityModeScreenState -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -34,6 +32,7 @@ internal class SecurityModeModel @Inject constructor( private val cardSettingsInteractor: CardSettingsInteractor, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsErrorHandler: AnalyticsErrorHandler, + private val appRouter: AppRouter, ) : Model() { private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value @@ -91,7 +90,7 @@ internal class SecurityModeModel @Inject constructor( is CompletionResult.Success -> { analyticsEventHandler.send(Settings.CardSettings.SecurityModeChanged(paramValue)) - store.dispatchNavigationAction(AppRouter::pop) + appRouter.pop() } is CompletionResult.Failure -> { val error = result.error @@ -99,7 +98,6 @@ internal class SecurityModeModel @Inject constructor( analyticsErrorHandler.sendErrorEvent(TangemSdkErrorEvent(error)) } } - else -> Unit } } } diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt deleted file mode 100644 index 3524b38f40..0000000000 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tangem.tap.features.disclaimer - -import android.net.Uri - -/** -[REDACTED_AUTHOR] - */ -interface Disclaimer { - fun getUri(): Uri - suspend fun accept() - suspend fun isAccepted(): Boolean -} - -abstract class BaseDisclaimer( - private val dataProvider: DisclaimerDataProvider, -) : Disclaimer { - - val baseUrl = "https://tangem.com" - - override suspend fun accept() { - dataProvider.accept() - } - - override suspend fun isAccepted(): Boolean = dataProvider.isAccepted() -} - -class TangemDisclaimer(dataProvider: DisclaimerDataProvider) : BaseDisclaimer(dataProvider) { - override fun getUri(): Uri = Uri.parse("$baseUrl/tangem_tos.html") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt deleted file mode 100644 index ba3c82563d..0000000000 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.features.disclaimer - -/** -[REDACTED_AUTHOR] - */ -interface DisclaimerDataProvider { - fun getLanguage(): String - fun getCardId(): String - suspend fun accept() - suspend fun isAccepted(): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt deleted file mode 100644 index 90f73cb4ad..0000000000 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.tap.features.disclaimer - -import com.tangem.domain.models.scan.CardDTO -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import java.util.Locale - -fun CardDTO.createDisclaimer(): Disclaimer { - val dataProvider = provideDisclaimerDataProvider(cardId) - return TangemDisclaimer(dataProvider) -} - -private fun provideDisclaimerDataProvider(cardId: String): DisclaimerDataProvider { - val cardRepository = store.inject(DaggerGraphState::cardRepository) - return object : DisclaimerDataProvider { - override fun getLanguage(): String = Locale.getDefault().language - override fun getCardId(): String = cardId - - override suspend fun accept() { - cardRepository.acceptTangemTOS() - } - - override suspend fun isAccepted(): Boolean { - return cardRepository.isTangemTOSAccepted() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/AffectsNavigation.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/AffectsNavigation.kt deleted file mode 100644 index e6d38c381c..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/AffectsNavigation.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.tangem.tap.features.intentHandler - -interface AffectsNavigation \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt deleted file mode 100644 index 7458f759ba..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.features.intentHandler - -import android.content.Intent - -/** -[REDACTED_AUTHOR] - */ -interface IntentHandler { - - fun handleIntent(intent: Intent?): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index ffca01be8a..5fb9bba066 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -21,7 +21,6 @@ import com.tangem.domain.balancehiding.BalanceHidingSettings import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.ClearApplicationIdUseCase import com.tangem.domain.notifications.GetApplicationIdUseCase import com.tangem.domain.notifications.SendPushTokenUseCase @@ -37,12 +36,10 @@ import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase import com.tangem.domain.staking.FetchStakingOptionsUseCase import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.tap.network.exchangeServices.SellService -import com.tangem.tap.proxy.AppStateHolder import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -79,8 +76,6 @@ internal class MainViewModel @Inject constructor( private val sendPushTokenUseCase: SendPushTokenUseCase, private val apiConfigsManager: ApiConfigsManager, private val multiQuoteUpdater: MultiQuoteUpdater, - private val appStateHolder: AppStateHolder, - private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val appRouterConfig: AppRouterConfig, private val sellService: SellService, private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider, @@ -143,8 +138,6 @@ internal class MainViewModel @Inject constructor( launch { fetchUserCountry() } } - subscribeToSelectedWallet() - // await while initial route stack is initialized appRouterConfig.initializedState.first { it } @@ -173,20 +166,6 @@ internal class MainViewModel @Inject constructor( } } - private fun subscribeToSelectedWallet() { - getSelectedWalletUseCase.invoke() - .mapLeft { emptyFlow() } - .onRight { wallet -> - wallet.distinctUntilChanged() - .onEach { - // FIXME Do not remove this call without checking implications !!! - appStateHolder.onUserWalletSelected(it) - } - .flowOn(dispatchers.io) - .launchIn(viewModelScope) - } - } - private suspend fun fetchStakingOptions() { fetchStakingOptionsUseCase() .onLeft { TangemLogger.e("Unable to fetch staking options: $it") } @@ -195,8 +174,6 @@ internal class MainViewModel @Inject constructor( private fun initializeOffRamp() { viewModelScope.launch { - appStateHolder.sellService = sellService - sellService.update() } } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt index 521d22b51a..757f98522c 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt @@ -2,19 +2,18 @@ package com.tangem.tap.features.onboarding import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.twinsIsTwinned +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store +import javax.inject.Inject +import javax.inject.Singleton -/** -[REDACTED_AUTHOR] - */ -object OnboardingHelper { +@Singleton +class OnboardingHelper @Inject constructor( + private val cardRepository: CardRepository, +) { suspend fun isOnboardingCase(response: ScanResponse): Boolean { - val cardRepository = store.inject(DaggerGraphState::cardRepository) val cardId = response.card.cardId return when { @@ -22,7 +21,7 @@ object OnboardingHelper { // if (response.visaCardActivationStatus == null) error("Visa card activation status is null") // // response.visaCardActivationStatus !is VisaCardActivationStatus.Activated - return true + true } response.cardTypesResolver.isTangemTwins() -> { diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultAppVersionProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAppVersionProvider.kt deleted file mode 100644 index a5093725e5..0000000000 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAppVersionProvider.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.network.auth - -import com.tangem.utils.version.AppVersionProvider -import com.tangem.wallet.BuildConfig - -internal class DefaultAppVersionProvider : AppVersionProvider { - - override val versionName: String = BuildConfig.VERSION_NAME - - override val versionCode: Int = BuildConfig.VERSION_CODE -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index 6ef9e06f96..6313d30db9 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -7,7 +7,6 @@ import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.tap.network.auth.* -import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -47,10 +46,4 @@ internal class AuthModule { fun provideP2PEthPoolAuthProvider(environmentConfig: EnvironmentConfig): P2PEthPoolAuthProvider { return DefaultP2PEthPoolAuthProvider(environmentConfig) } - - @Provides - @Singleton - fun provideAppVersionProvider(): AppVersionProvider { - return DefaultAppVersionProvider() - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index 285f2c46e9..e9bc94300b 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt @@ -17,7 +17,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.models.AssetRequirementsCondition -import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runCatching import com.tangem.utils.coroutines.runSuspendCatching @@ -26,7 +25,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull internal class DefaultRampManager( - private val sellService: Provider, + private val sellService: SellService, private val expressServiceFetcher: ExpressServiceFetcher, private val currenciesRepository: CurrenciesRepository, private val dispatchers: CoroutineDispatcherProvider, @@ -53,7 +52,7 @@ internal class DefaultRampManager( block = { val serviceCurrency = CryptoCurrencyConverter.convert(status.currency) - sellService().availableForSell(currency = serviceCurrency) + sellService.availableForSell(currency = serviceCurrency) }, catch = { raise(ScenarioUnavailabilityReason.NotSupportedBySellService(status.currency.name)) }, ) @@ -86,18 +85,36 @@ internal class DefaultRampManager( cryptoCurrency: CryptoCurrency, ): ScenarioUnavailabilityReason { val availabilityState = runSuspendCatching { - getExchangeableState() + getExchangeableState( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) }.getOrNull() ?: ExpressAvailabilityState.Error return availabilityState.toReason(cryptoCurrency.name) } + override suspend fun availableForSwap( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map { + val availabilityStates = runSuspendCatching { + getExchangeableStates( + userWalletId = userWalletId, + cryptoCurrencies = cryptoCurrencies, + ) + }.getOrNull() ?: cryptoCurrencies.associateWith { ExpressAvailabilityState.Error } + return availabilityStates.mapValues { entry -> + entry.value.toReason(entry.key.name) + } + } + override fun getSellInitializationStatus(): Flow { - return sellService.invoke().initializationStatus + return sellService.initializationStatus } override suspend fun fetchSellServiceData() { runCatching(dispatchers.io) { - sellService.invoke().update() + sellService.update() } } @@ -142,9 +159,42 @@ internal class DefaultRampManager( } } - private fun getExchangeableState(): ExpressAvailabilityState { - // In task [REDACTED_TASK_KEY], removed all checks to make all tokens available - return ExpressAvailabilityState.Available + private suspend fun getExchangeableState( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): ExpressAvailabilityState { + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() + ?: return ExpressAvailabilityState.Loading + + return when (asset) { + is Lce.Error -> ExpressAvailabilityState.Error + is Lce.Loading -> ExpressAvailabilityState.Loading + is Lce.Content -> { + val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) } + foundAsset?.isExchangeAvailable?.toSwapAvailabilityState() + ?: ExpressAvailabilityState.AssetNotFound + } + } + } + + private suspend fun getExchangeableStates( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map { + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() + ?: return cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading } + + return when (asset) { + is Lce.Error -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Error } + is Lce.Loading -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading } + is Lce.Content -> { + val foundAsset = asset.getOrNull() + cryptoCurrencies.associateWith { cryptoCurrency -> + foundAsset?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) }?.isExchangeAvailable + ?.toSwapAvailabilityState() ?: ExpressAvailabilityState.AssetNotFound + } + } + } } private suspend fun getOnrampAvailableState( @@ -178,6 +228,14 @@ internal class DefaultRampManager( } } + private fun Boolean.toSwapAvailabilityState(): ExpressAvailabilityState { + return if (this) { + ExpressAvailabilityState.Available + } else { + ExpressAvailabilityState.NotExchangeable + } + } + private fun Boolean.toOnrampAvailabilityState(): ExpressAvailabilityState { return if (this) { ExpressAvailabilityState.Available @@ -188,7 +246,7 @@ internal class DefaultRampManager( private fun CryptoCurrency.findAssetPredicate(assetId: ExpressAsset.ID): Boolean { val currencyAssedId = ExpressAsset.ID( - networkId = this.network.backendId, + networkId = this.network.rawId, contractAddress = (this as? CryptoCurrency.Token)?.contractAddress, ) diff --git a/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt b/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt deleted file mode 100644 index e85f806bdc..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.tangem.tap.proxy - -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.redux.ReduxStateHolder -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.onUserWalletSelected -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.network.exchangeServices.SellService -import org.rekotlin.Action -import org.rekotlin.Store -import javax.inject.Inject - -/** - * Holds objects from old modules, that missing in DI graph. - * Object sets manually to use in new modules and [AppStateHolder] proxies its to DI. - */ -class AppStateHolder @Inject constructor() : ReduxStateHolder { - - var mainStore: Store? = null - var sellService: SellService? = null - - override fun dispatch(action: Action) { - mainStore?.dispatch(action) - } - - override suspend fun dispatchWithMain(action: Action) { - mainStore?.dispatchWithMain(action) - } - - override suspend fun onUserWalletSelected(userWallet: UserWallet) { - mainStore?.onUserWalletSelected(userWallet) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt deleted file mode 100644 index 5c943d917b..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.tap.proxy.di - -import com.tangem.tap.proxy.AppStateHolder -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object ProxyModule { - - @Provides - @Singleton - fun provideAppStateHolder(): AppStateHolder { - return AppStateHolder() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt deleted file mode 100644 index ef0fab7352..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.domain.card.ScanCardUseCase -import com.tangem.domain.card.repository.CardSdkConfigRepository -import org.rekotlin.Action - -sealed interface DaggerGraphAction : Action { - - data class SetActivityDependencies( - val scanCardUseCase: ScanCardUseCase, - val cardSdkConfigRepository: CardSdkConfigRepository, - ) : DaggerGraphAction -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt deleted file mode 100644 index 0e822a4edc..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Middleware - -@Suppress("MemberNameEqualsClassName") -object DaggerGraphMiddleware { - val daggerGraphMiddleware: Middleware = { _, _ -> - { next -> - { action -> next(action) } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt deleted file mode 100644 index 6a2e43b96a..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Action - -object DaggerGraphReducer { - fun reduce(action: Action, state: AppState): DaggerGraphState { - if (action !is DaggerGraphAction) return state.daggerGraphState - - return internalReduce(action, state) - } - - private fun internalReduce(action: DaggerGraphAction, state: AppState): DaggerGraphState { - return when (action) { - is DaggerGraphAction.SetActivityDependencies -> state.daggerGraphState.copy( - scanCardUseCase = action.scanCardUseCase, - cardSdkConfigRepository = action.cardSdkConfigRepository, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt deleted file mode 100644 index 9f31ff291c..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.blockchainsdk.BlockchainSDKFactory -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.utils.TrackingContextProxy -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.navigation.share.ShareManager -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.data.card.TransactionSignerFactory -import com.tangem.datasource.connection.NetworkConnectionManager -import com.tangem.datasource.local.config.issuers.IssuersConfigStorage -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.domain.appcurrency.repository.AppCurrencyRepository -import com.tangem.domain.apptheme.repository.AppThemeModeRepository -import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository -import com.tangem.domain.card.ScanCardProcessor -import com.tangem.domain.card.ScanCardUseCase -import com.tangem.domain.card.ScanFailsRequester -import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetWalletMetaInfoUseCase -import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.repository.OnboardingRepository -import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles -import com.tangem.hot.sdk.TangemHotSdk -import com.tangem.operations.attestation.CardArtworksProvider -import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.proxy.AppStateHolder -import org.rekotlin.StateType - -data class DaggerGraphState( - val networkConnectionManager: NetworkConnectionManager? = null, - val cardScanningFeatureToggles: CardScanningFeatureToggles? = null, - val scanCardUseCase: ScanCardUseCase? = null, - val scanCardProcessor: ScanCardProcessor? = null, - val cardSdkConfigRepository: CardSdkConfigRepository? = null, - val appCurrencyRepository: AppCurrencyRepository? = null, - val walletManagersFacade: WalletManagersFacade? = null, - val appStateHolder: AppStateHolder? = null, - val appThemeModeRepository: AppThemeModeRepository? = null, - val balanceHidingRepository: BalanceHidingRepository? = null, - val walletsRepository: WalletsRepository? = null, - val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase? = null, - val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase? = null, - val cardRepository: CardRepository? = null, - val settingsRepository: SettingsRepository? = null, - val blockchainSDKFactory: BlockchainSDKFactory? = null, - val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase? = null, - val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase? = null, - val issuersConfigStorage: IssuersConfigStorage? = null, - val urlOpener: UrlOpener? = null, - val shareManager: ShareManager? = null, - val appRouter: AppRouter? = null, - val transactionSignerFactory: TransactionSignerFactory? = null, - val onboardingV2FeatureToggles: OnboardingV2FeatureToggles? = null, - val onboardingRepository: OnboardingRepository? = null, - val excludedBlockchains: ExcludedBlockchains? = null, - val appPreferencesStore: AppPreferencesStore? = null, - val clipboardManager: ClipboardManager? = null, - val settingsManager: SettingsManager? = null, - val uiMessageSender: UiMessageSender? = null, - val cardArworksProvider: CardArtworksProvider? = null, - val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory? = null, - val userWalletsListRepository: UserWalletsListRepository? = null, - val tangemHotSdk: TangemHotSdk? = null, - val trackingContextProxy: TrackingContextProxy? = null, - val scanFailsRequester: ScanFailsRequester? = null, -) : StateType \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index 595de7948b..f90ef24ac4 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -48,7 +48,6 @@ import com.tangem.hot.sdk.android.create import com.tangem.sdk.api.BackupServiceHolder import com.tangem.tap.common.SnackbarHandler import com.tangem.tap.common.analytics.events.Onboarding -import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.features.hot.TangemHotSDKProxy import com.tangem.tap.features.root.RootDetectedWarningComponent import com.tangem.tap.features.scanfails.ScanFailsComponent @@ -59,7 +58,6 @@ import com.tangem.tap.routing.component.RoutingComponent.Child import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.tap.routing.utils.ChildFactory import com.tangem.tap.routing.utils.DeepLinkFactory -import com.tangem.tap.store import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.R import dagger.assisted.Assisted @@ -291,7 +289,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor( } private fun checkForUnfinishedBackup() { - if (DemoHelper.tryHandle { store.state }) return componentScope.launch(dispatchers.main) { val scanResponse = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch messageSender.send(unfinishedBackupFoundDialog(scanResponse)) diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index a200fea92f..7351bc519d 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -138,6 +138,7 @@ internal class ChildFactory @Inject constructor( AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES AppRoute.ManageTokens.Source.ACCOUNT -> ManageTokensSource.ACCOUNT + AppRoute.ManageTokens.Source.WALLET -> ManageTokensSource.WALLET } val mode = route.accountId?.let { ManageTokensMode.Account(it) } ?: ManageTokensMode.None @@ -194,6 +195,9 @@ internal class ChildFactory @Inject constructor( source = params.source, ) }, + preselectedSection = route.preselectedSection, + shouldOpenExchanges = route.shouldOpenExchanges, + exchangesCount = route.exchangesCount, ), componentFactory = feedEntryComponentFactory, ) @@ -256,6 +260,11 @@ internal class ChildFactory @Inject constructor( OnboardingEntryComponent.Mode.ContinueFinalize is AppRoute.Onboarding.Mode.UpgradeHotWallet -> OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId) + is AppRoute.Onboarding.Mode.AddressSync -> + OnboardingEntryComponent.Mode.AddressSync( + mode.userWalletId, + mode.isWalletStarted, + ) }, ), componentFactory = onboardingEntryComponentFactory, @@ -298,11 +307,14 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = SwapComponent.Params( - currencyFrom = route.currencyFrom, - currencyTo = route.currencyTo, + cryptoCurrency = route.cryptoCurrency, userWalletId = route.userWalletId, - isInitialReverseOrder = route.isInitialReverseOrder, screenSource = route.screenSource, + currencyPosition = when (route.currencyPosition) { + AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM + AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO + AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY + }, tangemPayInput = route.tangemPayInput?.let { tangemPayInput -> SwapComponent.Params.TangemPayInput( cryptoAmount = tangemPayInput.cryptoAmount, @@ -457,7 +469,10 @@ internal class ChildFactory @Inject constructor( is AppRoute.Markets -> { createComponentChild( context = context, - params = FeedEntryRoute.MarketTokenList, + params = FeedEntryRoute.MarketTokenList( + preselectedOrder = route.preselectedOrder, + preselectedInterval = route.preselectedInterval, + ), componentFactory = feedEntryComponentFactory, ) } @@ -685,6 +700,15 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } + is AppRoute.News -> { + createComponentChild( + context = context, + params = FeedEntryRoute.NewsList( + preselectedCategoryId = route.categoryId, + ), + componentFactory = feedEntryComponentFactory, + ) + } } } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index fb1acbcd80..3e9d268dd5 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -8,6 +8,8 @@ import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -51,7 +53,9 @@ internal class DeepLinkFactory @Inject constructor( private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, + private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, + private val newsDeepLink: NewsDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -148,14 +152,16 @@ internal class DeepLinkFactory @Inject constructor( isFromOnNewIntent = isFromOnNewIntent, ) DeepLinkRoute.Staking.host -> stakingDeepLink.create(coroutineScope, queryParams) - DeepLinkRoute.Markets.host -> marketsDeepLink.create() + DeepLinkRoute.Markets.host -> marketsDeepLink.create(queryParams) DeepLinkRoute.MarketTokenDetail.host -> marketsTokenDetailDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.TokenExchanges.host -> marketsTokenExchangesDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.Buy.host -> buyDeepLink.create() DeepLinkRoute.Sell.host -> sellDeepLink.create() DeepLinkRoute.Swap.host -> swapDeepLink.create() DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) + DeepLinkRoute.News.host -> newsDeepLink.create(queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 250461aa6b..e1c13baa62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,6 @@ Tangem + Select Mock Card diff --git a/app/src/mocked/java/com/tangem/tap/core/security/MockAwareDeviceSecurityInfoProvider.kt b/app/src/mocked/java/com/tangem/tap/core/security/MockAwareDeviceSecurityInfoProvider.kt new file mode 100644 index 0000000000..9ccfeedce5 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/core/security/MockAwareDeviceSecurityInfoProvider.kt @@ -0,0 +1,30 @@ +package com.tangem.tap.core.security + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.security.DeviceSecurityInfoProvider + +/** In MOCK env reports a clean device; otherwise delegates (DexProtector RTC flags emulators). */ +internal class MockAwareDeviceSecurityInfoProvider( + private val real: DeviceSecurityInfoProvider, + private val apiConfigsManager: ApiConfigsManager, +) : DeviceSecurityInfoProvider { + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override val isRooted: Boolean + get() = if (isMockMode) false else real.isRooted + + override val isBootloaderUnlocked: Boolean + get() = if (isMockMode) false else real.isBootloaderUnlocked + + override val isXposed: Boolean + get() = if (isMockMode) false else real.isXposed + + override val isVulnerableToMediaTekExploit: Boolean + get() = if (isMockMode) false else real.isVulnerableToMediaTekExploit +} \ No newline at end of file diff --git a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt new file mode 100644 index 0000000000..eabe4ccda7 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt @@ -0,0 +1,129 @@ +package com.tangem.tap.data + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayWithdrawState +import com.tangem.domain.visa.model.TangemPayAuthTokens +import javax.inject.Inject +import javax.inject.Singleton + +private const val MOCK_CUSTOMER_WALLET_ADDRESS = "0x0000000000000000000000000000000000000002" +private const val MOCK_ACCESS_TOKEN = "mock-access-token" +private const val MOCK_REFRESH_TOKEN = "mock-refresh-token" +private const val MOCK_IDEMPOTENCY_KEY = "mock-idempotency-key" +private const val MOCK_TOKEN_EXPIRES_AT = 9_999_999_999L + +/** In MOCK env returns synthetic auth tokens + customer wallet address; otherwise delegates. */ +@Singleton +internal class MockAwareTangemPayStorage @Inject constructor( + private val real: DefaultTangemPayStorage, + private val apiConfigsManager: ApiConfigsManager, +) : TangemPayStorage { + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) { + if (isMockMode) return + real.storeCustomerWalletAddress(userWalletId, customerWalletAddress) + } + + override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? { + if (isMockMode) return MOCK_CUSTOMER_WALLET_ADDRESS + return real.getCustomerWalletAddress(userWalletId) + } + + override suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId) { + if (isMockMode) return + real.clearCustomerWalletAddress(userWalletId) + } + + override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) { + if (isMockMode) return + real.storeAuthTokens(customerWalletAddress, tokens) + } + + override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? { + if (isMockMode) { + return TangemPayAuthTokens( + accessToken = MOCK_ACCESS_TOKEN, + expiresAt = MOCK_TOKEN_EXPIRES_AT, + refreshToken = MOCK_REFRESH_TOKEN, + refreshExpiresAt = MOCK_TOKEN_EXPIRES_AT, + idempotencyKey = MOCK_IDEMPOTENCY_KEY, + ) + } + return real.getAuthTokens(customerWalletAddress) + } + + override suspend fun clearAuthTokens(customerWalletAddress: String) { + if (isMockMode) return + real.clearAuthTokens(customerWalletAddress) + } + + override suspend fun storeOrderId(customerWalletAddress: String, orderId: String) = + real.storeOrderId(customerWalletAddress, orderId) + + override suspend fun getOrderId(customerWalletAddress: String): String? = + real.getOrderId(customerWalletAddress) + + override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean = + real.getAddToWalletDone(customerWalletAddress) + + override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) = + real.storeAddToWalletDone(customerWalletAddress, isDone) + + override suspend fun clearOrderId(customerWalletAddress: String) = + real.clearOrderId(customerWalletAddress) + + override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) = + real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer) + + override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? { + if (isMockMode) return true + return real.checkCustomerWalletResult(userWalletId) + } + + override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) = + real.storeActiveWithdrawOrderId(userWalletId, orderId) + + override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) = + real.storeWithdrawOrder(userWalletId, data) + + override suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String? = + real.getActiveWithdrawOrderId(userWalletId) + + override suspend fun getWithdrawOrders(userWalletId: UserWalletId): List? = + real.getWithdrawOrders(userWalletId) + + override suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId) = + real.deleteActiveWithdrawOrder(userWalletId) + + override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId, orderId: String) = + real.deleteWithdrawOrder(userWalletId, orderId) + + override suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean) = + real.storeHideOnboardingBanner(userWalletId, hide) + + override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean = + real.getHideMainOnboardingBanner(userWalletId) + + override suspend fun storeTangemPayEligibility(eligibility: Set) = + real.storeTangemPayEligibility(eligibility) + + override suspend fun getTangemPayEligibility(): Set = real.getTangemPayEligibility() + + override suspend fun storeIsTangemPayDeactivated(userWalletId: UserWalletId) = + real.storeIsTangemPayDeactivated(userWalletId) + + override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean = + real.isTangemPayDeactivated(userWalletId) + + override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) = + real.clearAll(userWalletId, customerWalletAddress) +} \ No newline at end of file diff --git a/app/src/mocked/java/com/tangem/tap/di/core/security/SecurityMockedModule.kt b/app/src/mocked/java/com/tangem/tap/di/core/security/SecurityMockedModule.kt new file mode 100644 index 0000000000..9216155ec9 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/di/core/security/SecurityMockedModule.kt @@ -0,0 +1,25 @@ +package com.tangem.tap.di.core.security + +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.security.DeviceSecurityInfoProvider +import com.tangem.tap.core.security.DefaultDeviceSecurityInfoProvider +import com.tangem.tap.core.security.MockAwareDeviceSecurityInfoProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object SecurityMockedModule { + + @Provides + @Singleton + fun provideDeviceSecurityInfoProvider( + apiConfigsManager: ApiConfigsManager, + ): DeviceSecurityInfoProvider { + val real = DefaultDeviceSecurityInfoProvider() + return MockAwareDeviceSecurityInfoProvider(real = real, apiConfigsManager = apiConfigsManager) + } +} \ No newline at end of file diff --git a/app/src/mocked/java/com/tangem/tap/di/data/TangemPayStorageMockedModule.kt b/app/src/mocked/java/com/tangem/tap/di/data/TangemPayStorageMockedModule.kt new file mode 100644 index 0000000000..8ccf4a8ac4 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/di/data/TangemPayStorageMockedModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di.data + +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.tap.data.MockAwareTangemPayStorage +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayStorageMockedModule { + + @Binds + @Singleton + fun bindTangemPayStorage(impl: MockAwareTangemPayStorage): TangemPayStorage +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/core/security/SecurityModule.kt b/app/src/prodDi/java/com/tangem/tap/di/core/security/SecurityProductionModule.kt similarity index 92% rename from app/src/main/java/com/tangem/tap/di/core/security/SecurityModule.kt rename to app/src/prodDi/java/com/tangem/tap/di/core/security/SecurityProductionModule.kt index b4e64ccb37..0f65cf0c19 100644 --- a/app/src/main/java/com/tangem/tap/di/core/security/SecurityModule.kt +++ b/app/src/prodDi/java/com/tangem/tap/di/core/security/SecurityProductionModule.kt @@ -10,7 +10,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object SecurityModule { +internal object SecurityProductionModule { @Provides @Singleton diff --git a/app/src/prodDi/java/com/tangem/tap/di/data/TangemPayStorageProductionModule.kt b/app/src/prodDi/java/com/tangem/tap/di/data/TangemPayStorageProductionModule.kt new file mode 100644 index 0000000000..dffc33767d --- /dev/null +++ b/app/src/prodDi/java/com/tangem/tap/di/data/TangemPayStorageProductionModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di.data + +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.tap.data.DefaultTangemPayStorage +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayStorageProductionModule { + + @Binds + @Singleton + fun bindTangemPayStorage(impl: DefaultTangemPayStorage): TangemPayStorage +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/log/FileLogWriterTest.kt b/app/src/test/kotlin/com/tangem/tap/common/log/FileLogWriterTest.kt new file mode 100644 index 0000000000..ea9d224b42 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/log/FileLogWriterTest.kt @@ -0,0 +1,157 @@ +package com.tangem.tap.common.log + +import com.google.common.truth.Truth +import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.utils.logging.Severity +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FileLogWriterTest { + + private val appLogsStore: AppLogsStore = mockk(relaxUnitFun = true) + private val writer = FileLogWriter(appLogsStore) + + // region isLoggable filter + + @Test + fun `isLoggable returns true for Error severity`() { + Truth.assertThat(writer.isLoggable(Severity.Error, "tag")).isTrue() + } + + @Test + fun `isLoggable returns true for Info severity`() { + Truth.assertThat(writer.isLoggable(Severity.Info, "tag")).isTrue() + } + + @Test + fun `isLoggable returns false for Verbose severity`() { + Truth.assertThat(writer.isLoggable(Severity.Verbose, "tag")).isFalse() + } + + @Test + fun `isLoggable returns false for Debug severity`() { + Truth.assertThat(writer.isLoggable(Severity.Debug, "tag")).isFalse() + } + + @Test + fun `isLoggable returns false for Warn severity`() { + Truth.assertThat(writer.isLoggable(Severity.Warn, "tag")).isFalse() + } + + @Test + fun `isLoggable returns false for Assert severity`() { + Truth.assertThat(writer.isLoggable(Severity.Assert, "tag")).isFalse() + } + + @Test + fun `isLoggable result is independent of the tag value`() { + Truth.assertThat(writer.isLoggable(Severity.Info, "")).isTrue() + Truth.assertThat(writer.isLoggable(Severity.Info, "anything")).isTrue() + Truth.assertThat(writer.isLoggable(Severity.Debug, "")).isFalse() + Truth.assertThat(writer.isLoggable(Severity.Debug, "anything")).isFalse() + } + + // endregion + + // region write delegation + + @Test + fun `write forwards tag, message, throwable and shouldSanitize to AppLogsStore`() { + // Arrange + val throwable = IllegalStateException("boom") + + // Act + writer.write( + severity = Severity.Error, + tag = "MyTag", + message = "error happened", + throwable = throwable, + shouldSanitize = true, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "MyTag", + message = "error happened", + throwable = throwable, + shouldSanitize = true, + ) + } + } + + @Test + fun `write forwards null throwable as null`() { + // Act + writer.write( + severity = Severity.Info, + tag = "Tag", + message = "info", + throwable = null, + shouldSanitize = true, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "Tag", + message = "info", + throwable = null, + shouldSanitize = true, + ) + } + } + + @Test + fun `write forwards shouldSanitize false to AppLogsStore so sanitizer is bypassed`() { + // Act + writer.write( + severity = Severity.Info, + tag = "Tag", + message = "raw payload", + throwable = null, + shouldSanitize = false, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "Tag", + message = "raw payload", + throwable = null, + shouldSanitize = false, + ) + } + } + + @Test + fun `write delegates regardless of severity (filtering is the caller's job)`() { + // The contract: TangemLogger asks isLoggable first; if a caller bypasses that and + // invokes write directly, the writer should still delegate to the store. + Severity.entries.forEach { severity -> + // Act + writer.write( + severity = severity, + tag = "Tag", + message = "msg-$severity", + throwable = null, + shouldSanitize = true, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "Tag", + message = "msg-$severity", + throwable = null, + shouldSanitize = true, + ) + } + } + } + + // endregion +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/log/LogcatLogWriterTest.kt b/app/src/test/kotlin/com/tangem/tap/common/log/LogcatLogWriterTest.kt new file mode 100644 index 0000000000..57a2b8fb7d --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/log/LogcatLogWriterTest.kt @@ -0,0 +1,213 @@ +package com.tangem.tap.common.log + +import android.util.Log +import com.tangem.utils.logging.Severity +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class LogcatLogWriterTest { + + private val writer = LogcatLogWriter() + + @BeforeEach + fun setUp() { + mockkStatic(Log::class) + every { Log.println(any(), any(), any()) } returns 0 + every { Log.getStackTraceString(any()) } returns "STACK" + } + + @AfterEach + fun tearDown() { + unmockkStatic(Log::class) + } + + // region Severity → Android priority mapping + + @Test + fun `Verbose severity maps to Log VERBOSE priority`() { + // Act + writer.write(Severity.Verbose, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.VERBOSE, "Tag", any()) } + } + + @Test + fun `Debug severity maps to Log DEBUG priority`() { + // Act + writer.write(Severity.Debug, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.DEBUG, "Tag", any()) } + } + + @Test + fun `Info severity maps to Log INFO priority`() { + // Act + writer.write(Severity.Info, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "Tag", any()) } + } + + @Test + fun `Warn severity maps to Log WARN priority`() { + // Act + writer.write(Severity.Warn, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.WARN, "Tag", any()) } + } + + @Test + fun `Error severity maps to Log ERROR priority`() { + // Act + writer.write(Severity.Error, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.ERROR, "Tag", any()) } + } + + @Test + fun `Assert severity maps to Log ASSERT priority`() { + // Act + writer.write(Severity.Assert, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.ASSERT, "Tag", any()) } + } + + // endregion + + // region Box layout + + @Test + fun `single-line message is wrapped between top and bottom borders`() { + // Act + writer.write(Severity.Info, "Tag", "hello", throwable = null, shouldSanitize = true) + + // Assert + verifySequence { + Log.println(Log.INFO, "Tag", match { it.startsWith("┌") }) + Log.println(Log.INFO, "Tag", "│ hello") + Log.println(Log.INFO, "Tag", match { it.startsWith("└") }) + } + } + + @Test + fun `each line of a multi-line message is printed as a separate logcat entry`() { + // Arrange + val sep = System.lineSeparator() + val message = "first${sep}second${sep}third" + + // Act + writer.write(Severity.Debug, "Tag", message, throwable = null, shouldSanitize = true) + + // Assert + verifySequence { + Log.println(Log.DEBUG, "Tag", match { it.startsWith("┌") }) + Log.println(Log.DEBUG, "Tag", "│ first") + Log.println(Log.DEBUG, "Tag", "│ second") + Log.println(Log.DEBUG, "Tag", "│ third") + Log.println(Log.DEBUG, "Tag", match { it.startsWith("└") }) + } + } + + // endregion + + // region Throwable handling + + @Test + fun `throwable is rendered via Log getStackTraceString`() { + // Arrange + val throwable = RuntimeException("boom") + every { Log.getStackTraceString(throwable) } returns "STACK" + + // Act + writer.write(Severity.Error, "Tag", "fail", throwable = throwable, shouldSanitize = true) + + // Assert + verify(exactly = 1) { Log.getStackTraceString(throwable) } + } + + @Test + fun `null throwable does not invoke getStackTraceString`() { + // Act + writer.write(Severity.Info, "Tag", "no throwable", throwable = null, shouldSanitize = true) + + // Assert + verify(exactly = 0) { Log.getStackTraceString(any()) } + } + + // endregion + + // region Chunking of long messages + + @Test + fun `message under CHUNK_SIZE bytes produces a single content line`() { + // Arrange + val message = "a".repeat(3999) + + // Act + writer.write(Severity.Info, "Tag", message, throwable = null, shouldSanitize = true) + + // Assert — top border + 1 content line + bottom border + verify(exactly = 3) { Log.println(Log.INFO, "Tag", any()) } + } + + @Test + fun `message exceeding CHUNK_SIZE bytes is split into multiple chunks`() { + // Arrange — 9000 ASCII bytes → chunks of 4000 + 4000 + 1000 = 3 chunks + val message = "a".repeat(9000) + + // Act + writer.write(Severity.Info, "Tag", message, throwable = null, shouldSanitize = true) + + // Assert — top border + 3 content lines + bottom border + verify(exactly = 5) { Log.println(Log.INFO, "Tag", any()) } + } + + // endregion + + // region Tag truncation + + @Test + fun `tag longer than 23 chars is truncated on legacy Android API stub`() { + // Arrange — in the unit-test Android stub, Build.VERSION.SDK_INT == 0, + // triggering the legacy truncation path. + val longTag = "a".repeat(50) + + // Act + writer.write(Severity.Info, longTag, "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "a".repeat(23), any()) } + } + + @Test + fun `tag of MAX_TAG_LENGTH chars is not truncated`() { + // Arrange + val tag = "a".repeat(23) + + // Act + writer.write(Severity.Info, tag, "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "a".repeat(23), any()) } + } + + @Test + fun `short tag is forwarded verbatim`() { + // Act + writer.write(Severity.Info, "Short", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "Short", any()) } + } + + // endregion +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/DefaultUserWalletSelectedHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/DefaultUserWalletSelectedHandlerTest.kt new file mode 100644 index 0000000000..ee434cb45c --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/domain/DefaultUserWalletSelectedHandlerTest.kt @@ -0,0 +1,147 @@ +package com.tangem.tap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.sdk.api.TangemSdkManager +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultUserWalletSelectedHandlerTest { + + private val trackingContextProxy = mockk(relaxed = true) + private val tangemSdkManager = mockk(relaxed = true) + private val settingsRepository = mockk() + private val cardSdkConfigRepository = mockk(relaxed = true) + private val appScope = TestAppCoroutineScope() + + private lateinit var handler: DefaultUserWalletSelectedHandler + + @BeforeEach + fun setup() { + clearMocks(trackingContextProxy, tangemSdkManager, settingsRepository, cardSdkConfigRepository) + handler = DefaultUserWalletSelectedHandler( + trackingContextProxy = trackingContextProxy, + tangemSdkManager = tangemSdkManager, + settingsRepository = settingsRepository, + cardSdkConfigRepository = cardSdkConfigRepository, + appCoroutineScope = appScope, + ) + } + + @Test + fun `cold wallet with access code and save-codes enabled applies biometric policy`() = runTest { + val userWallet = coldWalletWith(isAccessCodeSet = true) + coEvery { settingsRepository.shouldSaveAccessCodes() } returns true + + handler(userWallet) + + verify(exactly = 1) { trackingContextProxy.setContext(userWallet) } + verify(exactly = 1) { tangemSdkManager.changeDisplayedCardIdNumbersCount(userWallet.scanResponse) } + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + } + + @Test + fun `cold wallet without access code keeps biometric policy off even when save-codes enabled`() = runTest { + assertThat(capturePolicyFor(shouldSaveAccessCodes = true, isAccessCodeSet = false)).isFalse() + } + + @Test + fun `cold wallet with access code keeps biometric policy off when save-codes disabled`() = runTest { + assertThat(capturePolicyFor(shouldSaveAccessCodes = false, isAccessCodeSet = true)).isFalse() + } + + @Test + fun `cold wallet without access code and save-codes disabled keeps biometric policy off`() = runTest { + assertThat(capturePolicyFor(shouldSaveAccessCodes = false, isAccessCodeSet = false)).isFalse() + } + + @Test + fun `hot wallet only updates tracking context`() = runTest { + val hotWallet = mockk() + + handler(hotWallet) + + verify(exactly = 1) { trackingContextProxy.setContext(hotWallet) } + verify(exactly = 0) { tangemSdkManager.changeDisplayedCardIdNumbersCount(any()) } + coVerify(exactly = 0) { settingsRepository.shouldSaveAccessCodes() } + verify(exactly = 0) { cardSdkConfigRepository.setAccessCodeRequestPolicy(any()) } + } + + @Test + fun `consecutive invocations both run policy update`() = runTest { + val firstWallet = coldWalletWith(isAccessCodeSet = true) + val secondWallet = coldWalletWith(isAccessCodeSet = false) + coEvery { settingsRepository.shouldSaveAccessCodes() } returns true + + handler(firstWallet) + handler(secondWallet) + + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) } + } + + @Test + fun `new invocation cancels in-flight job so only latest side effects are applied`() = runTest { + val firstWallet = coldWalletWith(isAccessCodeSet = true) + val secondWallet = coldWalletWith(isAccessCodeSet = false) + + val firstCallGate = CompletableDeferred() + var callIndex = 0 + coEvery { settingsRepository.shouldSaveAccessCodes() } coAnswers { + callIndex++ + if (callIndex == 1) firstCallGate.await() else true + } + + val firstHandlerJob = launch { handler(firstWallet) } + runCurrent() + + handler(secondWallet) + + firstCallGate.complete(true) + firstHandlerJob.join() + + verify(exactly = 0) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) } + verify(exactly = 1) { trackingContextProxy.setContext(secondWallet) } + } + + private suspend fun capturePolicyFor(shouldSaveAccessCodes: Boolean, isAccessCodeSet: Boolean): Boolean { + val userWallet = coldWalletWith(isAccessCodeSet = isAccessCodeSet) + coEvery { settingsRepository.shouldSaveAccessCodes() } returns shouldSaveAccessCodes + val captured = slot() + + handler(userWallet) + + verify { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = capture(captured)) } + return captured.captured + } + + private fun coldWalletWith(isAccessCodeSet: Boolean): UserWallet.Cold { + val baseScanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(maxWalletCount = 2), + derivedKeys = emptyMap(), + ) + val scanResponse: ScanResponse = baseScanResponse.copy( + card = baseScanResponse.card.copy( + cardId = if (isAccessCodeSet) "CARD-WITH-CODE" else "CARD-NO-CODE", + isAccessCodeSet = isAccessCodeSet, + ), + ) + return MockUserWalletFactory.create(scanResponse = scanResponse) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 0cb5e4db8f..7a1c5173b5 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -6,6 +6,8 @@ import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -55,7 +57,7 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } private val marketsDeepLinkFactory = mockk(relaxed = true) { - every { create() } returns mockk() + every { create(any()) } returns mockk() } private val marketsTokenDetailDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() @@ -86,6 +88,15 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } + private val newsDeepLinkFactory = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + + private val marketsTokenExchangesDeepLinkFactory = + mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val mockedUri = mockk(relaxed = true) private val isFromOnNewIntent: Boolean = false @@ -103,12 +114,14 @@ class DeepLinkFactoryTest { stakingDeepLink = stakingDeepLinkFactory, marketsDeepLink = marketsDeepLinkFactory, marketsTokenDetailDeepLink = marketsTokenDetailDeepLinkFactory, + marketsTokenExchangesDeepLink = marketsTokenExchangesDeepLinkFactory, buyDeepLink = buyDeepLinkFactory, sellDeepLink = sellDeepLinkFactory, swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, newsDetailsDeepLink = newsDeeplink, + newsDeepLink = newsDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -308,7 +321,7 @@ class DeepLinkFactoryTest { every { mockedUri.host } returns "markets" deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() - verify { marketsDeepLinkFactory.create() } + verify { marketsDeepLinkFactory.create(any()) } // Test Sell every { mockedUri.host } returns "sell" @@ -425,6 +438,21 @@ class DeepLinkFactoryTest { verify { onrampDeepLinkFactory.create(eq(testScope), eq(emptyMap())) } } + @Test + fun `handleTangemDeepLinks routes news host to dedicated handler`() = runTest { + every { mockedUri.scheme } returns "tangem" + every { mockedUri.host } returns "news" + every { mockedUri.query } returns null + every { mockedUri.queryParameterNames } returns emptySet() + every { mockedUri.getQueryParameter(any()) } returns null + + deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + + verify { newsDeepLinkFactory.create(eq(emptyMap())) } + } + @Test fun `handleTangemDeepLinks routes to promo handler`() = runTest { every { mockedUri.scheme } returns "tangem" diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index ea5ec5fdcc..3a6eac7c41 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -10,6 +10,9 @@ import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId @@ -146,6 +149,7 @@ sealed class AppRoute(val path: String) : Route { STORIES, SETTINGS, ACCOUNT, + WALLET, } } @@ -192,18 +196,15 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Swap( - val currencyFrom: CryptoCurrency, - val currencyTo: CryptoCurrency? = null, val userWalletId: UserWalletId, - val isInitialReverseOrder: Boolean = false, + val cryptoCurrency: CryptoCurrency? = null, val screenSource: String, + val currencyPosition: CurrencyPosition = CurrencyPosition.ANY, val tangemPayInput: TangemPayInput? = null, ) : AppRoute( path = "/swap" + - "/${currencyFrom.id.value}" + - "/${currencyTo?.id?.value}" + - "/${userWalletId.stringValue}" + - "/$isInitialReverseOrder", + "/${cryptoCurrency?.id?.value}" + + "/${userWalletId.stringValue}", ) { @Serializable data class TangemPayInput( @@ -212,6 +213,13 @@ sealed class AppRoute(val path: String) : Route { val depositAddress: String, val isWithdrawal: Boolean, ) + + @Serializable + enum class CurrencyPosition { + FROM, + TO, + ANY, + } } @Serializable @@ -252,7 +260,10 @@ sealed class AppRoute(val path: String) : Route { ) : AppRoute(path = "/wallet_hardware_backup/${userWalletId.stringValue}") @Serializable - data object Markets : AppRoute(path = "/markets") + data class Markets( + val preselectedOrder: PreselectedMarketsOrder? = null, + val preselectedInterval: PreselectedMarketsInterval? = null, + ) : AppRoute(path = "/markets") @Serializable data class MarketsTokenDetails( @@ -260,6 +271,9 @@ sealed class AppRoute(val path: String) : Route { val appCurrency: AppCurrency, val shouldShowPortfolio: Boolean, val analyticsParams: AnalyticsParams? = null, + val preselectedSection: PreselectedTokenDetailsSection? = null, + val shouldOpenExchanges: Boolean = false, + val exchangesCount: Int? = null, ) : AppRoute(path = "/markets_token_details/${token.id}/$shouldShowPortfolio") { @Serializable @@ -320,6 +334,7 @@ sealed class AppRoute(val path: String) : Route { data object RecreateWalletTwin : Mode() // reset twins data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog) data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet + data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode() } } @@ -474,4 +489,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") + + @Serializable + data class News( + val categoryId: Int? = null, + ) : AppRoute(path = "/news") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index d57eb5e7f0..b9caabe5d0 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -60,6 +60,10 @@ sealed class DeepLinkRoute { override val host: String = "promo" } + data object TokenExchanges : DeepLinkRoute() { + override val host: String = "token_exchanges" + } + data object OnboardVisa : DeepLinkRoute() { override val host: String = "onboard-visa" } @@ -67,6 +71,10 @@ sealed class DeepLinkRoute { data object PayApp : DeepLinkRoute() { override val host: String = "tangem.com" } + + data object News : DeepLinkRoute() { + override val host: String = "news" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 630b68ded8..8f745a3b32 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -15,4 +15,9 @@ object DeeplinkConst { const val REF_KEY = "ref" const val CAMPAIGN_KEY = "campaign" const val NAME_KEY = "name" + const val ORDER_KEY = "order" + const val INTERVAL_KEY = "interval" + const val SECTION_KEY = "section" + const val CATEGORY_ID_KEY = "category_id" + const val NEWS_ID_KEY = "news_id" } \ No newline at end of file diff --git a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt index 596d7cd6c2..bdcb99e48d 100644 --- a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt +++ b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt @@ -88,6 +88,26 @@ internal class DeepLinkBuilderTest { assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action?$key1=$value1&$key2=$value2") } + @Test + fun `news host with categoryId produces expected uri`() { + val result = deepLinkBuilder + .setAction("news") + .addQueryParam(DeeplinkConst.CATEGORY_ID_KEY, "5") + .build() + + assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.CATEGORY_ID_KEY}=5") + } + + @Test + fun `news host with newsId produces expected uri`() { + val result = deepLinkBuilder + .setAction("news") + .addQueryParam(DeeplinkConst.NEWS_ID_KEY, "20533") + .build() + + assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.NEWS_ID_KEY}=20533") + } + @Test fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() { // GIVEN diff --git a/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt b/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt index 9994416d24..7cf1eebdc6 100644 --- a/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt +++ b/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt @@ -47,7 +47,7 @@ fun StakingBalance.Data.getTotalStakingBalance(blockchainId: String): BigDecimal * StakeKit-specific extension to get total balance including rewards. */ private fun StakingBalance.Data.StakeKit.getTotalWithRewardsStakingBalanceStakeKit(blockchainId: String): BigDecimal { - return if (BlockchainUtils.isIncludeStakingTotalBalance(blockchainId = blockchainId)) { + return if (BlockchainUtils.isIncludeStakingTotalBalance(networkId = blockchainId)) { balance.items.sumOf { it.amount } } else { getRewardStakingBalance() @@ -58,7 +58,7 @@ private fun StakingBalance.Data.StakeKit.getTotalWithRewardsStakingBalanceStakeK * StakeKit-specific extension to get total staked balance excluding rewards. */ private fun StakingBalance.Data.StakeKit.getTotalStakingBalanceStakeKit(blockchainId: String): BigDecimal { - return if (BlockchainUtils.isIncludeStakingTotalBalance(blockchainId = blockchainId)) { + return if (BlockchainUtils.isIncludeStakingTotalBalance(networkId = blockchainId)) { balance.items .filterNot { it.type == BalanceType.REWARDS } .sumOf { it.amount } diff --git a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt index b62a7c78ce..91298fd491 100644 --- a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt +++ b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt @@ -7,7 +7,7 @@ object TangemBlogUrlBuilder { suspend fun build(post: Post): String { return TangemSiteUrlBuilder.url( - path = "/blog/post/${post.path}/", + path = "/embed/blog/post/${post.path}/", campaign = "articles", ) } @@ -16,14 +16,6 @@ object TangemBlogUrlBuilder { val path: String - data object SeedNotify : Post { - override val path: String = "seed-notify" - } - - data object SeedNotifySecond : Post { - override val path: String = "tangem-resolves-log-issue" - } - data object SeedPhraseRiskySolution : Post { override val path: String = "seed-phrase-faq" } @@ -39,5 +31,21 @@ object TangemBlogUrlBuilder { data object HowToScan : Post { override val path: String = "scan-tangem-card" } + + data object HowToStake : Post { + override val path: String = "how-to-stake-cryptocurrency" + } + + data object GiveRevokePermission : Post { + override val path: String = "give-revoke-permission" + } + + data object HowYieldModeWorks : Post { + override val path: String = "yield-mode" + } + + data object AboutCrossChainBridges : Post { + override val path: String = "an-overview-of-cross-chain-bridges" + } } } \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt index d5f73cbee0..79aea5c82e 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt @@ -55,8 +55,7 @@ class MockCryptoCurrencyFactory(private val userWallet: UserWallet.Cold = defaul ) val network = Network( - id = Network.ID(blockchain.id, derivationPath), - backendId = blockchain.toNetworkId(), + id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath), name = blockchain.fullName, isTestnet = blockchain.isTestnet(), derivationPath = derivationPath, @@ -85,12 +84,11 @@ class MockCryptoCurrencyFactory(private val userWallet: UserWallet.Cold = defaul return CryptoCurrency.Token( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId(blockchain.id), + body = CryptoCurrency.ID.Body.NetworkId(blockchain.toNetworkId()), suffix = CryptoCurrency.ID.Suffix.RawID(blockchain.id), ), network = Network( - id = Network.ID(value = blockchain.id, derivationPath), - backendId = "NEVER-MIND", + id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath), name = blockchain.fullName, currencySymbol = "NEVER-MIND", derivationPath = derivationPath, diff --git a/common/ui-markets/build.gradle.kts b/common/ui-markets/build.gradle.kts index b83e4298e2..7eb6c53bd1 100644 --- a/common/ui-markets/build.gradle.kts +++ b/common/ui-markets/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) id("configuration") } @@ -12,13 +14,23 @@ dependencies { /** Project - Core */ implementation(projects.core.ui) implementation(projects.core.utils) + implementation(projects.core.navigation) + implementation(projects.core.analytics) /** Project - Common */ implementation(projects.common.uiCharts) implementation(projects.common.ui) + implementation(projects.common.routing) /** Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.staking) + implementation(projects.domain.staking.models) + implementation(projects.domain.onramp.models) + implementation(projects.domain.offramp) + implementation(projects.domain.demo) implementation(deps.lifecycle.compose) implementation(deps.compose.foundation) @@ -26,4 +38,8 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) implementation(deps.kotlin.immutable.collections) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) } \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt index 9f5a2c1e63..66511d08d6 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt @@ -72,14 +72,14 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif TangemIcon( tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44), modifier = Modifier - .size(40.dp) + .size(TangemTheme.dimens2.x10) .layoutId(layoutId = TangemRowLayoutId.HEAD), ) TokenTitle( modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.START_TOP) - .padding(horizontal = TangemTheme.dimens2.x2), + .padding(start = TangemTheme.dimens2.x3), name = model.name, currencySymbol = model.currencySymbol, ) @@ -97,7 +97,7 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif TokenSubtitle( modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM) - .padding(end = TangemTheme.dimens2.x2, start = TangemTheme.dimens2.x3), + .padding(start = TangemTheme.dimens2.x3), ratingPosition = model.ratingPosition, marketCap = model.marketCap, stakingRate = model.stakingRate, @@ -112,7 +112,7 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif if (windowSize.widthAtLeast(WindowSizeType.Small)) { Chart( modifier = Modifier - .padding(start = TangemTheme.dimens2.x2) + .padding(start = TangemTheme.dimens2.x3) .layoutId(layoutId = TangemRowLayoutId.TAIL), chartType = model.chartType, chartRawData = model.chartData, @@ -140,7 +140,7 @@ private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier modifier = Modifier.alignByBaseline(), text = currencySymbol, color = TangemTheme.colors2.text.neutral.secondary, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, overflow = TextOverflow.Visible, ) @@ -197,7 +197,7 @@ private fun RowScope.TokenRatingPlace(ratingPosition: String?, ratingColor: Colo textAlign = TextAlign.Center, text = ratingPosition ?: MINUS, color = ratingColor, - style = TangemTheme.typography2.captionSemibold12.copy(letterSpacing = 0.sp), + style = TangemTheme.typography2.captionMedium12.copy(letterSpacing = 0.sp), maxLines = 1, ) @@ -216,7 +216,7 @@ private fun RowScope.TokenMarketCapText(text: String, ratingColor: Color, modifi modifier = modifier.alignByBaseline(), text = text, color = ratingColor, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt new file mode 100644 index 0000000000..d4133cc3ef --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt @@ -0,0 +1,14 @@ +package com.tangem.common.ui.markets.action + +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.model.TokenActionsState + +data class CryptoCurrencyData( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val actions: List, + val isAccountMode: Boolean, + val account: AccountStatus.CryptoPortfolio, +) \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt new file mode 100644 index 0000000000..88b65e767a --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt @@ -0,0 +1,111 @@ +package com.tangem.common.ui.markets.action + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList + +@Immutable +sealed class QuickActionUM( + open val title: TextReference, + open val description: TextReference, + @param:DrawableRes open val icon: Int, + open val isLongClickAvailable: Boolean = false, +) { + + sealed class V1( + override val title: TextReference, + override val description: TextReference, + @param:DrawableRes override val icon: Int, + override val isLongClickAvailable: Boolean = false, + ) : QuickActionUM( + title = title, + description = description, + icon = icon, + isLongClickAvailable = isLongClickAvailable, + ) { + data object Buy : V1( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.buy_token_description), + icon = R.drawable.ic_plus_24, + ) + + data class Exchange( + val shouldShowBadge: Boolean, + ) : V1( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.exсhange_token_description), + icon = R.drawable.ic_exchange_vertical_24, + ) + + data object Receive : V1( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.receive_token_description), + icon = R.drawable.ic_arrow_down_24, + isLongClickAvailable = true, + ) + + data object Stake : V1( + title = resourceReference(R.string.common_stake), + description = resourceReference(R.string.stake_token_description), + icon = R.drawable.ic_staking_24, + ) + + data class YieldMode( + private val apy: String, + ) : V1( + title = resourceReference(R.string.common_yield_mode), + description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), + icon = R.drawable.ic_analytics_up_mini_24, + ) + } + + sealed class V2( + override val title: TextReference, + override val description: TextReference, + @param:DrawableRes override val icon: Int, + override val isLongClickAvailable: Boolean = false, + ) : QuickActionUM( + title = title, + description = description, + icon = icon, + isLongClickAvailable = isLongClickAvailable, + ) { + data object Buy : V2( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.quick_action_buy_description), + icon = R.drawable.ic_credit_card_20, + ) + + data class Exchange( + val shouldShowBadge: Boolean, + ) : V2( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.quick_action_swap_description), + icon = R.drawable.ic_exchange_mini_24, + ) + + data object Receive : V2( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.quick_action_receive_description), + icon = R.drawable.ic_qrcode_new_24, + isLongClickAvailable = true, + ) + + data object Stake : V2( + title = resourceReference(R.string.common_stake), + description = resourceReference(R.string.stake_token_description), + icon = R.drawable.ic_staking_24, + ) + + data class YieldMode( + private val apy: String, + ) : V2( + title = resourceReference(R.string.common_yield_mode), + description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), + icon = R.drawable.ic_analytics_up_mini_24, + ) + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt new file mode 100644 index 0000000000..27d4bfac71 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt @@ -0,0 +1,9 @@ +package com.tangem.common.ui.markets.action + +import kotlinx.collections.immutable.ImmutableList + +data class QuickActions( + val actions: ImmutableList, + val onQuickActionClick: (QuickActionUM) -> Unit, + val onQuickActionLongClick: (QuickActionUM) -> Unit, +) \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt new file mode 100644 index 0000000000..47f53be359 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt @@ -0,0 +1,112 @@ +package com.tangem.common.ui.markets.action + +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +object QuickActionsConverter { + + fun quickActions( + cryptoData: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + isRedesignEnabled: Boolean, + ): QuickActions { + return QuickActions( + actions = toQuickActions(cryptoData.actions, isRedesignEnabled), + onQuickActionClick = { quickActionUM -> + when (quickActionUM) { + QuickActionUM.V1.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.V1.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V1.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V1.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.V1.YieldMode -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.YieldMode, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V2.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.V2.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V2.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V2.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.V2.YieldMode -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.YieldMode, + cryptoCurrencyData = cryptoData, + ) + } + }, + onQuickActionLongClick = { actionUM -> + if (actionUM == QuickActionUM.V1.Receive || actionUM == QuickActionUM.V2.Receive) { + tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.CopyAddress, + cryptoCurrencyData = cryptoData, + ) + } + }, + ) + } + + fun toQuickActions(actions: List, isRedesignEnabled: Boolean) = + if (isRedesignEnabled) { + redesignedQuickActions(actions) + } else { + legacyQuickActions(actions) + } + + private fun redesignedQuickActions(actions: List): ImmutableList { + return buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.V2.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.V2.Exchange(action.shouldShowBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.V2.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.V2.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.V2.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } + + private fun legacyQuickActions(actions: List): ImmutableList { + return buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.V1.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.V1.Exchange(action.shouldShowBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.V1.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.V1.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.V1.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt index f35729eaa8..f33fee7c3d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt @@ -1,14 +1,14 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state +package com.tangem.common.ui.markets.action import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.R import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.features.feed.impl.R import kotlinx.collections.immutable.ImmutableList -internal data class TokenActionsBSContentUM( +data class TokenActionsBSContentUM( val title: String, val actions: ImmutableList, val onActionClick: (Action) -> Unit, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt similarity index 83% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 3f6071b417..e24e2ee1ed 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -1,7 +1,8 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.model +package com.tangem.common.ui.markets.action import com.tangem.common.routing.AppRoute import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.common.ui.markets.R import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent @@ -18,9 +19,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.offramp.GetOfframpUrlUseCase import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.tokens.model.TokenActionsState -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM -import com.tangem.features.feed.impl.R import com.tangem.utils.Provider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -28,7 +26,7 @@ import dagger.assisted.AssistedInject import kotlinx.collections.immutable.toImmutableList @Suppress("LongParameterList") -internal class TokenActionsHandler @AssistedInject constructor( +class TokenActionsHandler @AssistedInject constructor( private val router: Router, private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, @@ -45,7 +43,7 @@ internal class TokenActionsHandler @AssistedInject constructor( add(TokenActionsBSContentUM.Action.Sell) } - fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: CryptoCurrencyData) { onHandleQuickAction( HandledQuickAction( action = action, @@ -79,13 +77,13 @@ internal class TokenActionsHandler @AssistedInject constructor( } private fun showDemoModeWarning() { - val message = DialogMessage( + val message = DialogMessage.Companion( message = resourceReference(R.string.alert_demo_feature_disabled), ) messageSender.send(message) } - private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onCopyAddress(cryptoCurrencyData: CryptoCurrencyData) { val cryptoCurrencyStatus = cryptoCurrencyData.status val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return val addresses = networkAddress.availableAddresses @@ -97,7 +95,7 @@ internal class TokenActionsHandler @AssistedInject constructor( uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied))) } - private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onBuyClick(cryptoCurrencyData: CryptoCurrencyData) { router.push( AppRoute.Onramp( userWalletId = cryptoCurrencyData.userWallet.walletId, @@ -107,7 +105,7 @@ internal class TokenActionsHandler @AssistedInject constructor( ) } - private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onSellClick(cryptoCurrencyData: CryptoCurrencyData) { getOfframpUrlUseCase( cryptoCurrencyStatus = cryptoCurrencyData.status, appCurrencyCode = currentAppCurrency().code, @@ -117,18 +115,17 @@ internal class TokenActionsHandler @AssistedInject constructor( } } - private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) { router.push( AppRoute.Swap( - currencyFrom = cryptoCurrencyData.status.currency, + cryptoCurrency = cryptoCurrencyData.status.currency, userWalletId = cryptoCurrencyData.userWallet.walletId, - isInitialReverseOrder = true, screenSource = AnalyticsParam.ScreensSources.Markets.value, ), ) } - private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onSendClick(cryptoCurrencyData: CryptoCurrencyData) { val route = AppRoute.SendEntryPoint( userWalletId = cryptoCurrencyData.userWallet.walletId, currency = cryptoCurrencyData.status.currency, @@ -136,7 +133,7 @@ internal class TokenActionsHandler @AssistedInject constructor( router.push(route) } - private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onStakeClick(cryptoCurrencyData: CryptoCurrencyData) { val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } ?.let { it as TokenActionsState.ActionState.Stake } ?.option ?: return @@ -150,7 +147,7 @@ internal class TokenActionsHandler @AssistedInject constructor( ) } - private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onYieldModeClick(cryptoCurrencyData: CryptoCurrencyData) { val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() .firstOrNull()?.apy ?: return @@ -173,6 +170,6 @@ internal class TokenActionsHandler @AssistedInject constructor( data class HandledQuickAction( val action: TokenActionsBSContentUM.Action, - val cryptoCurrencyData: PortfolioData.CryptoCurrencyData, + val cryptoCurrencyData: CryptoCurrencyData, ) } \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/BalanceColumn.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/BalanceColumn.kt new file mode 100644 index 0000000000..36625d5b13 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/BalanceColumn.kt @@ -0,0 +1,170 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.flicker +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.utils.StringsSigns + +@Composable +fun BalanceColumn(balanceState: BalanceDisplayState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { + if (isBalanceHidden) { + HiddenBalance(modifier) + return + } + when (balanceState) { + is BalanceDisplayState.Loading -> LoadingBalanceColumn(modifier) + is BalanceDisplayState.Flickering -> FlickeringBalanceColumn(balanceState, modifier) + is BalanceDisplayState.Stale -> StaleBalanceColumn(balanceState, modifier) + is BalanceDisplayState.Unreachable -> UnreachableBalanceColumn(modifier) + is BalanceDisplayState.Loaded -> LoadedBalanceColumn(balanceState, modifier) + } +} + +@Composable +private fun BalanceColumnLayout(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) +} + +@Composable +private fun LoadingBalanceColumn(modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + RectangleShimmer(modifier = Modifier.size(width = 108.dp, height = 20.dp), radius = 20.dp) + RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 16.dp), radius = 20.dp) + } +} + +@Composable +private fun FlickeringBalanceColumn(state: BalanceDisplayState.Flickering, modifier: Modifier = Modifier) { + val flickerModifier = Modifier.flicker(isFlickering = true) + BalanceColumnLayout(modifier) { + Text( + modifier = flickerModifier, + text = state.fiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = flickerModifier, + text = state.cryptoBalance.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun StaleBalanceColumn(state: BalanceDisplayState.Stale, modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.ic_error_sync_24), + contentDescription = null, + tint = TangemTheme.colors2.markers.iconGray, + ) + SpacerW(TangemTheme.dimens2.x0_5) + Text( + text = state.fiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = state.cryptoBalance.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun UnreachableBalanceColumn(modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Text( + text = StringsSigns.DASH_SIGN, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResourceSafe(R.string.common_unreachable), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.status.attention, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + SpacerW(TangemTheme.dimens2.x0_5) + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.ic_alert_triange_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.status.attention, + ) + } + } +} + +@Composable +private fun LoadedBalanceColumn(state: BalanceDisplayState.Loaded, modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Text( + text = state.fiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = state.cryptoBalance.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } +} + +@Composable +private fun HiddenBalance(modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Text( + text = StringsSigns.THREE_STARS, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = StringsSigns.THREE_STARS, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/GroupedUserAssetItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/GroupedUserAssetItem.kt new file mode 100644 index 0000000000..26115886e6 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/GroupedUserAssetItem.kt @@ -0,0 +1,82 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.* +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.res.TangemTheme + +@Composable +fun GroupedUserAssetItem(item: UserAssetItemUM.Grouped, modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + TangemRowContainer( + modifier = Modifier.clickable(onClick = item.onClick), + content = { + LayeringIcons( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x1), + tangemIconUM = item.icon, + count = item.tokensCount, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = item.tokenName, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = pluralStringResourceSafe(R.plurals.common_tokens_count, item.tokensCount, item.tokensCount), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + BalanceColumn( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + balanceState = item.balanceState, + isBalanceHidden = item.isBalanceHidden, + ) + + TangemButton( + modifier = Modifier + .padding(start = TangemTheme.dimens2.x2) + .layoutId(TangemRowLayoutId.TAIL), + buttonUM = TangemButtonUM( + type = TangemButtonType.Secondary, + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_chevron_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X10, + onClick = item.onClick, + ), + ) + }, + ) + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/LayeringIcons.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/LayeringIcons.kt new file mode 100644 index 0000000000..2ca0775133 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/LayeringIcons.kt @@ -0,0 +1,99 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +private const val MIN_STACKED_ICON_COUNT = 1 +private const val MAX_STACKED_ICON_COUNT = 3 +private const val FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 1 +private const val SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 2 +private const val MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER = 2 +private const val FIRST_BACK_LAYER_ICON_ALPHA = 0.4f +private const val SECOND_BACK_LAYER_ICON_ALPHA = 0.2f + +@Composable +fun LayeringIcons( + tangemIconUM: TangemIconUM, + modifier: Modifier = Modifier, + count: Int = MIN_STACKED_ICON_COUNT, + layerHorizontalShift: Dp = TangemTheme.dimens2.x1, + iconSize: Dp = TangemTheme.dimens2.x10, +) { + require(count > 0) + + val stackTrailingWidth = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER + + Box( + modifier = modifier.size( + width = iconSize + stackTrailingWidth, + height = iconSize, + ), + ) { + val baseIconModifier = Modifier + .align(Alignment.TopStart) + .size(iconSize) + + if (count >= MAX_STACKED_ICON_COUNT) { + TangemIcon( + tangemIconUM = tangemIconUM, + modifier = baseIconModifier + .offset(x = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER) + .alpha(SECOND_BACK_LAYER_ICON_ALPHA), + ) + } + if (count >= MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER) { + TangemIcon( + tangemIconUM = tangemIconUM, + modifier = baseIconModifier + .offset(x = layerHorizontalShift * FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER) + .alpha(FIRST_BACK_LAYER_ICON_ALPHA), + ) + } + TangemIcon( + tangemIconUM = tangemIconUM, + modifier = baseIconModifier, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun LayeringIconsPreview() { + val previewCurrencyIcon = TangemIconUM.Currency( + currencyIconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = null, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ) + TangemThemePreview { + Column(horizontalAlignment = Alignment.End) { + LayeringIcons(count = MIN_STACKED_ICON_COUNT, tangemIconUM = previewCurrencyIcon) + LayeringIcons( + count = MAX_STACKED_ICON_COUNT, + tangemIconUM = previewCurrencyIcon, + ) + LayeringIcons( + count = MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER, + tangemIconUM = previewCurrencyIcon, + ) + } + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt new file mode 100644 index 0000000000..62d8e486f5 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt @@ -0,0 +1,117 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.res.TangemTheme + +@Composable +fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Single, modifier: Modifier = Modifier) { + TangemRowContainer( + modifier = modifier.clickable(onClick = item.onClick), + content = { + TangemIcon( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .size(40.dp) + .padding(end = TangemTheme.dimens2.x1), + tangemIconUM = item.icon, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = item.tokenName, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + + if (shouldUsePriceBlock) { + PriceBlock( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + priceChangeState = item.priceChangeState, + fiatRate = item.fiatRate, + balanceState = item.balanceState, + ) + } else { + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = item.networkName, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + + BalanceColumn( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + balanceState = item.balanceState, + isBalanceHidden = item.isBalanceHidden, + ) + }, + ) +} + +@Composable +private fun PriceBlock( + priceChangeState: PriceChangeState, + fiatRate: String?, + balanceState: BalanceDisplayState, + modifier: Modifier = Modifier, +) { + val isDisabled = remember(balanceState) { + balanceState is BalanceDisplayState.Unreachable + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + if (fiatRate != null) { + Text( + text = fiatRate, + style = TangemTheme.typography2.captionMedium12, + color = if (isDisabled) { + TangemTheme.colors2.text.status.disabled + } else { + TangemTheme.colors2.text.neutral.secondary + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + AnimatedContent( + targetState = priceChangeState, + contentKey = { it::class }, + ) { animatedState -> + when (animatedState) { + is PriceChangeState.Content -> { + PriceChangeInPercent( + valueInPercent = animatedState.valueInPercent, + type = animatedState.type, + textStyle = TangemTheme.typography2.captionMedium12, + isDisabled = isDisabled, + ) + } + PriceChangeState.Unknown -> Unit + } + } + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt new file mode 100644 index 0000000000..2aff7dd2e3 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt @@ -0,0 +1,177 @@ +package com.tangem.common.ui.markets.tokenselector + +import android.content.res.Configuration +import androidx.compose.animation.core.EaseOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.Fade +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.rememberHazeState + +@Composable +fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors2.surface.level2, + content = { content -> + TokenSelectorContent( + content = content, + onDismiss = config.onDismissRequest, + embedded = false, + ) + }, + ) +} + +@Composable +fun TokenSelectorEmbeddedContent( + content: TokenSelectorContentUM, + scrollBottomInset: Dp, + modifier: Modifier = Modifier, +) { + TokenSelectorContent( + content = content, + embedded = true, + modifier = modifier, + scrollBottomInset = scrollBottomInset, + ) +} + +@Composable +private fun TokenSelectorContent( + content: TokenSelectorContentUM, + embedded: Boolean, + modifier: Modifier = Modifier, + scrollBottomInset: Dp = 0.dp, + onDismiss: () -> Unit = {}, +) { + val hazeState = rememberHazeState() + var topBarHeight by remember { mutableStateOf(0.dp) } + val topContentPadding = if (embedded) { + 0.dp + } else { + topBarHeight + } + + Box(modifier = modifier.fillMaxWidth()) { + val bottomFadeReserve = if (embedded) 0.dp else TangemTheme.dimens2.x10 + val bottomListPadding = bottomFadeReserve + scrollBottomInset + LazyColumn( + modifier = Modifier.hazeSourceTangem(state = hazeState, 1f), + contentPadding = PaddingValues( + start = TangemTheme.dimens2.x4, + end = TangemTheme.dimens2.x4, + top = topContentPadding, + bottom = bottomListPadding, + ), + ) { + tokenSelectorSectionItems(content.sections) + } + if (!embedded) { + TokenSelectorSheetTopBar( + modifier = Modifier.align(Alignment.TopEnd), + onDismiss = onDismiss, + hazeState = hazeState, + onChangeHeight = { topBarHeight = it }, + ) + Fade( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + height = TangemTheme.dimens2.x10, + ) + } + } +} + +@Composable +private fun TokenSelectorSheetTopBar( + hazeState: HazeState, + onDismiss: () -> Unit, + onChangeHeight: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + val bgColor = TangemTheme.colors2.surface.level2 + val density = LocalDensity.current + TangemTopBar( + modifier = modifier + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + onChangeHeight(coordinates.size.height.toDp()) + } + } + } + .hazeEffectTangem(state = hazeState) { + backgroundColor = bgColor + progressive = HazeProgressive.verticalGradient( + startIntensity = .55f, + endIntensity = 0f, + preferPerformance = true, + easing = EaseOut, + ) + }, + type = TangemTopBarType.BottomSheet, + title = resourceReference(R.string.markets_search_portfolio_header), + endContent = { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier + .size(TangemTheme.dimens2.x11) + .background( + color = TangemTheme.colors2.button.backgroundSecondary, + shape = CircleShape, + ) + .clickableSingle(onClick = onDismiss) + .padding(TangemTheme.dimens2.x2_5), + ) + }, + ) +} + +@Preview +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TokenSelectorBottomSheetPreview( + @PreviewParameter(TokenSelectorContentPreviewProvider::class) content: TokenSelectorContentUM, +) { + TangemThemePreviewRedesign { + TokenSelectorBottomSheet( + config = TangemBottomSheetConfig( + onDismissRequest = {}, + content = content, + isShown = true, + ), + ) + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentPreviewProvider.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentPreviewProvider.kt new file mode 100644 index 0000000000..d6301236d5 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentPreviewProvider.kt @@ -0,0 +1,117 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import kotlinx.collections.immutable.persistentListOf + +@Suppress("StringLiteralDuplication") +class TokenSelectorContentPreviewProvider : + CollectionPreviewParameterProvider( + listOf( + tokenSelectorPreviewSimple(), + tokenSelectorPreviewWithAccountHeaders(), + tokenSelectorPreviewMultiWallet(), + ), + ) + +private fun tokenSelectorPreviewSimple(): TokenSelectorContentUM { + return TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.TokenGroup( + accountHeader = null, + items = persistentListOf( + previewTokenItem(id = "eth", name = "Ethereum", symbol = "ETH"), + previewTokenItem(id = "btc", name = "Bitcoin", symbol = "BTC"), + ), + ), + ), + ) +} + +private fun tokenSelectorPreviewWithAccountHeaders(): TokenSelectorContentUM { + val accountIcon = CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Wallet, + color = CryptoPortfolioIcon.Color.CaribbeanBlue, + ) + return TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.TokenGroup( + accountHeader = AccountHeaderData( + accountName = stringReference(value = "Main account"), + cryptoPortfolioIcon = accountIcon, + ), + items = persistentListOf( + previewTokenItem(id = "eth_main", name = "Ethereum", symbol = "ETH"), + ), + ), + TokenSelectorSectionUM.TokenGroup( + accountHeader = AccountHeaderData( + accountName = stringReference(value = "Trading"), + cryptoPortfolioIcon = accountIcon, + ), + items = persistentListOf( + previewTokenItem(id = "sol_trade", name = "Solana", symbol = "SOL"), + previewTokenItem(id = "avax_trade", name = "Avalanche", symbol = "AVAX"), + ), + ), + ), + ) +} + +private fun tokenSelectorPreviewMultiWallet(): TokenSelectorContentUM { + return TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.WalletHeader(walletName = "Cold wallet"), + TokenSelectorSectionUM.TokenGroup( + accountHeader = null, + items = persistentListOf( + previewTokenItem(id = "btc_cold", name = "Bitcoin", symbol = "BTC"), + ), + ), + TokenSelectorSectionUM.WalletHeader(walletName = "Hot wallet"), + TokenSelectorSectionUM.TokenGroup( + accountHeader = null, + items = persistentListOf( + previewTokenItem(id = "eth_hot", name = "Ethereum", symbol = "ETH"), + previewTokenItem(id = "usdt_hot", name = "Tether", symbol = "USDT"), + ), + ), + ), + ) +} + +private fun previewTokenItem(id: String, name: String, symbol: String): UserAssetItemUM.Single { + val cryptoRef = stringReference(value = "1.234 $symbol") + val fiatRef = stringReference(value = "$1,234.56") + return UserAssetItemUM.Single( + id = id, + icon = TangemIconUM.Currency( + CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.ic_ethereumpow_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + tokenName = name, + tokenSymbol = symbol, + fiatRate = "$98,765.43", + priceChangeState = PriceChangeState.Content( + valueInPercent = "+2.34%", + type = PriceChangeType.UP, + ), + balanceState = BalanceDisplayState.Loaded( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ), + isBalanceHidden = false, + onClick = {}, + networkName = "Ethereum", + ) +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentUM.kt new file mode 100644 index 0000000000..55994b5a72 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentUM.kt @@ -0,0 +1,29 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class TokenSelectorContentUM( + val sections: ImmutableList, +) : TangemBottomSheetConfigContent + +@Immutable +data class AccountHeaderData( + val accountName: TextReference, + val cryptoPortfolioIcon: CryptoPortfolioIcon, +) + +@Immutable +sealed interface TokenSelectorSectionUM { + + data class WalletHeader(val walletName: String) : TokenSelectorSectionUM + + data class TokenGroup( + val accountHeader: AccountHeaderData?, + val items: ImmutableList, + ) : TokenSelectorSectionUM +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorList.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorList.kt new file mode 100644 index 0000000000..eddbe4fc8e --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorList.kt @@ -0,0 +1,138 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.common.ui.account.getResId +import com.tangem.common.ui.account.getUiColor +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import kotlinx.collections.immutable.ImmutableList + +fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList) { + sections.forEachIndexed { index, section -> + when (section) { + is TokenSelectorSectionUM.WalletHeader -> { + item(key = "wallet_${section.walletName}_$index") { + WalletHeaderSection(section) + } + } + is TokenSelectorSectionUM.TokenGroup -> { + if (section.items.isEmpty()) return@forEachIndexed + + if (index > 0 && sections[index - 1] is TokenSelectorSectionUM.TokenGroup) { + item(key = "spacer_before_group_$index") { + SpacerH(TangemTheme.dimens2.x2) + } + } + + val lastIndex = if (section.accountHeader != null) { + section.items.size + } else { + section.items.lastIndex.coerceAtLeast(0) + } + + section.accountHeader?.let { header -> + item(key = "account_header_$index") { + TokenGroupAccountHeaderRow( + data = header, + modifier = Modifier.tokenGroupRowDecoration( + currentIndex = 0, + lastIndex = lastIndex, + ), + ) + } + } + + val indexOffset = if (section.accountHeader != null) 1 else 0 + section.items.forEachIndexed { itemIndex, single -> + item(key = "token_${single.id}_$index") { + SingleUserAssetItem( + item = single, + modifier = Modifier.tokenGroupRowDecoration( + currentIndex = indexOffset + itemIndex, + lastIndex = lastIndex, + ), + shouldUsePriceBlock = false, + ) + } + } + } + } + } +} + +@Composable +private fun Modifier.tokenGroupRowDecoration(currentIndex: Int, lastIndex: Int): Modifier = + this.roundedShapeItemDecoration( + currentIndex = currentIndex, + lastIndex = lastIndex, + addDefaultPadding = false, + radius = TangemTheme.dimens2.x6, + backgroundColor = TangemTheme.colors2.surface.level3, + ) + +@Composable +private fun WalletHeaderSection(section: TokenSelectorSectionUM.WalletHeader) { + Row( + modifier = Modifier + .padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2) + .padding(horizontal = TangemTheme.dimens2.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + text = section.walletName, + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_key_card_20), + modifier = Modifier.size(TangemTheme.dimens2.x5), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Composable +private fun TokenGroupAccountHeaderRow(data: AccountHeaderData, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2) + .padding(horizontal = TangemTheme.dimens2.x4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x4), + imageVector = ImageVector.vectorResource(data.cryptoPortfolioIcon.value.getResId()), + tint = data.cryptoPortfolioIcon.color.getUiColor(), + contentDescription = null, + ) + Text( + text = data.accountName.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/UserAssetItemUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/UserAssetItemUM.kt new file mode 100644 index 0000000000..1729f70d6c --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/UserAssetItemUM.kt @@ -0,0 +1,61 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface BalanceDisplayState { + + data class Loaded( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data class Flickering( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data class Stale( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data object Loading : BalanceDisplayState + data object Unreachable : BalanceDisplayState +} + +@Immutable +sealed interface UserAssetItemUM { + val id: String + val icon: TangemIconUM + val tokenName: String + val tokenSymbol: String + val onClick: () -> Unit + + data class Single( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val fiatRate: String?, + val priceChangeState: PriceChangeState, + val balanceState: BalanceDisplayState, + val isBalanceHidden: Boolean, + val networkName: String, + override val onClick: () -> Unit, + ) : UserAssetItemUM + + data class Grouped( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val tokensCount: Int, + val balanceState: BalanceDisplayState, + val isBalanceHidden: Boolean, + override val onClick: () -> Unit, + ) : UserAssetItemUM +} \ No newline at end of file diff --git a/common/ui/build.gradle.kts b/common/ui/build.gradle.kts index 71620a30d6..0cd3cd3495 100644 --- a/common/ui/build.gradle.kts +++ b/common/ui/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.common.ui" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.common) @@ -29,6 +33,7 @@ dependencies { implementation(projects.core.utils) implementation(projects.core.res) implementation(projects.libs.crypto) + implementation(projects.libs.blockchainSdk) /** Project - Domain */ implementation(projects.domain.appCurrency.models) @@ -48,4 +53,8 @@ dependencies { implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + + /** Tests */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt index 1955e4b9a0..1e759205fc 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -27,6 +27,8 @@ class AccountCryptoPortfolioItemStateConverter( private val priceChangeLce: Lce? = null, private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null, private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null, + private val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?)? = null, + private val subtitle2StateProvider: ((Lce) -> Subtitle2State?)? = null, ) : Converter { override fun convert(value: TotalFiatBalance): TokenItemState { @@ -40,11 +42,11 @@ class AccountCryptoPortfolioItemStateConverter( private fun Account.CryptoPortfolio.mapToContentState( fiatBalance: TotalFiatBalance.Loaded, ): TokenItemState.Content { - val subtitle2State = priceChangeLce?.fold( - ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, - ifError = { null }, - ifContent = { priceChange -> priceChange.toSubtitle2State() }, - ) + val subtitle2State = if (priceChangeLce != null && subtitle2StateProvider != null) { + subtitle2StateProvider(priceChangeLce) + } else { + createSubtitle2State(priceChangeLce) + } return TokenItemState.Content( id = account.accountId.toItemId(), iconState = AccountIconItemStateConverter().convert(this), @@ -59,11 +61,8 @@ class AccountCryptoPortfolioItemStateConverter( ), isAvailable = false, ), - fiatAmountState = FiatAmountState.Content( - text = fiatBalance.amount - .format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, - isFlickering = fiatBalance.source == StatusSource.CACHE, - ), + fiatAmountState = fiatAmountStateProvider?.invoke(fiatBalance) + ?: createFiatAmountState(fiatBalance, appCurrency), subtitle2State = subtitle2State, onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } }, @@ -127,4 +126,32 @@ class AccountCryptoPortfolioItemStateConverter( type = this.value.getPriceChangeType(), isFlickering = this.source.isFlickering(), ) + + private fun createSubtitle2State(priceChangeLce: Lce?): Subtitle2State? { + return priceChangeLce?.fold( + ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, + ifError = { null }, + ifContent = { priceChange -> priceChange.toSubtitle2State() }, + ) + } + + companion object { + fun createFiatAmountState(fiatBalance: TotalFiatBalance, appCurrency: AppCurrency): FiatAmountState { + return when (fiatBalance) { + TotalFiatBalance.Failed, + TotalFiatBalance.Loading, + -> FiatAmountState.Empty + + is TotalFiatBalance.Loaded -> FiatAmountState.Content( + text = fiatBalance.amount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + isFlickering = fiatBalance.source == StatusSource.CACHE, + ) + } + } + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt index 8e755eb8bc..76873d6c56 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.R @@ -19,6 +20,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.accounts.AccountRowTestTags /** * Displays a row representing an account with an icon, title, and subtitle. @@ -54,6 +56,7 @@ fun AccountRow( name = title, icon = icon, size = AccountIconSize.Default, + modifier = Modifier.testTag(AccountRowTestTags.ICON), ) Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), @@ -72,6 +75,7 @@ fun AccountRow( @Composable private fun Title(title: TextReference) { Text( + modifier = Modifier.testTag(AccountRowTestTags.TITLE), text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, @@ -83,6 +87,7 @@ private fun Title(title: TextReference) { @Composable private fun Subtitle(subtitle: TextReference) { Text( + modifier = Modifier.testTag(AccountRowTestTags.SUBTITLE), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption2, text = subtitle.resolveReference(), diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt index f45c250868..e74695b572 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt @@ -3,16 +3,15 @@ package com.tangem.common.ui.account import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -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.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -22,12 +21,16 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.R import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.domain.models.account.AccountName @Composable @@ -80,6 +83,66 @@ fun PortfolioSelectRow( } } +@Composable +fun PortfolioSelectRowV2( + state: PortfolioSelectUM, + modifier: Modifier = Modifier, + leftContent: @Composable RowScope.() -> Unit = {}, +) { + val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet + TangemRowContainer( + modifier.clickable(enabled = state.isMultiChoice, onClick = state.onClick), + ) { + if (state.icon != null) { + Box( + modifier = Modifier.layoutId(TangemRowLayoutId.HEAD), + contentAlignment = Alignment.Center, + ) { + AccountIcon( + modifier = Modifier.padding(end = TangemTheme.dimens2.x3), + name = state.name, + icon = state.icon, + size = AccountIconSize.RedesignedDefault, + ) + } + } + + Row( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + verticalAlignment = Alignment.Bottom, + ) { + leftContent() + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(leftText), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + ) + } + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = state.name.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + ) + if (state.isMultiChoice) { + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .size(TangemTheme.dimens2.x5), + painter = painterResource(id = R.drawable.ic_select_choice_20), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.secondary, + ) + } + } +} + @Immutable data class PortfolioSelectUM( val icon: AccountIconUM?, @@ -101,6 +164,20 @@ private fun PortfolioSelectRowPreview(@PreviewParameter(PreviewProvider::class) } } +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PortfolioSelectRowPreviewV2(@PreviewParameter(PreviewProvider::class) state: PortfolioSelectUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + PortfolioSelectRowV2( + state = state, + modifier = Modifier.background(TangemTheme.colors2.surface.level3), + ) + } + } +} + private class PreviewProvider : PreviewParameterProvider { override val values: Sequence diff --git a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt index 5e0f916630..3793e29d50 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt @@ -154,7 +154,7 @@ private fun Preview(@PreviewParameter(PreviewProvider::class) state: AddTokenUM) } } -private class PreviewProvider : PreviewParameterProvider { +internal class PreviewProvider : PreviewParameterProvider { private val tokenState get() = TokenItemState.Content( id = UUID.randomUUID().toString(), diff --git a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt new file mode 100644 index 0000000000..2f5e65b38a --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt @@ -0,0 +1,214 @@ +package com.tangem.common.ui.addtoken + +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.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.PortfolioSelectRowV2 +import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.button.PrimaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +fun AddTokenContentV2(state: AddTokenUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenHeader( + cryptoCurrencyIconState = state.tokenToAdd.iconState, + tokenName = state.tokenToAdd.titleState, + ) + Column { + if (state.portfolio.isMultiChoice) { + PortfolioSelectRowV2( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + state = state.portfolio, + ) + + SpacerH(TangemTheme.dimens2.x2) + } + + NetworkRow(state.network) + } + + SpacerH(TangemTheme.dimens2.x4) + + AddButton( + modifier = Modifier.fillMaxWidth(), + state = state.button, + ) + } +} + +@Composable +private fun TokenHeader( + cryptoCurrencyIconState: CurrencyIconState, + tokenName: TokenItemState.TitleState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x8), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + state = cryptoCurrencyIconState, + iconSize = 70.dp, + shouldDisplayNetwork = false, + ) + + SpacerH(TangemTheme.dimens2.x4) + + when (tokenName) { + is TokenItemState.TitleState.Content -> { + Text( + text = tokenName.text.resolveReference(), + style = TangemTheme.typography2.headingSemibold28, + color = TangemTheme.colors2.text.neutral.primary, + ) + } + TokenItemState.TitleState.Loading -> { + RectangleShimmer( + modifier = Modifier.size(width = TangemTheme.dimens2.x17, height = TangemTheme.dimens2.x9), + radius = TangemTheme.dimens2.x25, + ) + } + TokenItemState.TitleState.Locked -> { + Box( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level4, + shape = RoundedCornerShape(TangemTheme.dimens2.x25), + ), + ) + } + } + } +} + +@Composable +private fun NetworkRow(state: AddTokenUM.Network, modifier: Modifier = Modifier) { + TangemRowContainer( + modifier + .clickable(enabled = state.editable, onClick = state.onClick) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .size(TangemTheme.dimens2.x10) + .padding(end = TangemTheme.dimens2.x3), + tint = Color.Unspecified, + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + ) + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = stringResourceSafe(R.string.wc_common_network), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + ) + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = state.name.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + ) + if (state.editable) { + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .size(TangemTheme.dimens2.x5), + painter = painterResource(id = com.tangem.common.ui.R.drawable.ic_select_choice_20), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.secondary, + ) + } + } +} + +@Composable +private fun AddButton(state: AddTokenUM.Button, modifier: Modifier = Modifier) { + val isIconExists = state.isEnabled && state.isTangemIconVisible + PrimaryTangemButton( + modifier = modifier, + text = state.text, + onClick = state.onConfirmClick, + tangemIconUM = if (isIconExists) { + TangemIconUM.Icon( + iconRes = R.drawable.ic_tangem_24, + tintReference = { + if (state.isEnabled) { + TangemTheme.colors2.graphic.neutral.primaryInverted + } else { + TangemTheme.colors2.graphic.neutral.quaternary + } + }, + ) + } else { + null + }, + iconPosition = TangemButtonIconPosition.Start, + isEnabled = state.isEnabled, + size = TangemButtonSize.X12, + shape = TangemButtonShape.Rounded, + isLoading = state.showProgress, + ) +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PreviewProvider::class) state: AddTokenUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level2) + .padding(horizontal = 16.dp), + ) { + AddTokenContentV2(state = state) + } + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index c73dd77911..a8d3ad4fe0 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.orMaskWithStars diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt index 6adccd58a0..f7322e95af 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt @@ -32,6 +32,7 @@ import com.tangem.core.ui.format.bigdecimal.uncapped import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BaseAmountBlockTestTags +import com.tangem.utils.StringsSigns @Composable fun AmountBlockV2( @@ -39,6 +40,7 @@ fun AmountBlockV2( isClickDisabled: Boolean, isEditingDisabled: Boolean, modifier: Modifier = Modifier, + shouldShowApproximatePrefix: Boolean = false, onClick: (() -> Unit)? = null, extraContent: @Composable () -> Unit = {}, ) { @@ -71,7 +73,7 @@ fun AmountBlockV2( balance = amountState.availableBalanceCrypto, currencyTitle = currencyTitle, currencyIconState = amountState.tokenIconState, - firstAmount = firstAmount, + firstAmount = if (shouldShowApproximatePrefix) StringsSigns.TILDE_SIGN + firstAmount else firstAmount, secondAmount = secondAmount, isClickDisabled = isClickDisabled, isEditingDisabled = isEditingDisabled, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/CurrencyIconStateBuilder.kt similarity index 95% rename from core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt rename to common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/CurrencyIconStateBuilder.kt index 204df382e5..1d6029deaf 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/CurrencyIconStateBuilder.kt @@ -1,10 +1,11 @@ -package com.tangem.core.ui.components.currency.icon +package com.tangem.common.ui.components.currency.icon import androidx.annotation.DrawableRes import androidx.compose.ui.graphics.Color +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.getTintForTokenIcon -import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon import com.tangem.domain.models.currency.CryptoCurrency diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt similarity index 95% rename from core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt rename to common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt index c954f883c4..c177757a06 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt @@ -1,8 +1,8 @@ -package com.tangem.core.ui.components.currency.icon.converter +package com.tangem.common.ui.components.currency.icon.converter +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.getTintForTokenIcon -import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -91,7 +91,7 @@ class CryptoCurrencyToIconStateConverter( isGrayscale = isGrayscale, fallbackTint = tint, fallbackBackground = background, - shouldShowCustomBadge = token.isCustom && showCustomBadge, // `true` for tokens with custom derivation + shouldShowCustomBadge = token.isCustom && showCustomBadge, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt new file mode 100644 index 0000000000..e23fd4042e --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -0,0 +1,451 @@ +package com.tangem.common.ui.earn + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.innerShadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.earn.EarnBlockUM.Type +import com.tangem.core.ui.R +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.ds.button.TangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.res.R as CoreResR + +private const val TINTED_BACKGROUND_ALPHA = 0.1f +private const val TINTED_BORDER_ALPHA = 0.1f +private const val TINTED_INNER_SHADOW_ALPHA = 0.3f +private const val GLOW_ALPHA = 0.7f +private val BorderWidth = 1.dp +private val InnerShadowBlur = 20.dp +private val ShimmerSubtitleWidth = 78.dp +private val LoaderSize = 12.dp +private val LoaderStrokeWidth = 1.5.dp + +@Composable +fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) { + when (state) { + is EarnBlockUM.Loading -> EarnBlockLoading(modifier) + is EarnBlockUM.Content -> EarnBlockContent(state, modifier) + } +} + +@Composable +private fun EarnBlockLoading(modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + TangemRowContainer( + modifier = modifier + .clip(shape) + .background(TangemTheme.colors2.surface.level3) + .border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape), + contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), + content = { + CircleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10), + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) + }, + ) +} + +@Composable +private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + + val clickModifier = state.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier + + TangemRowContainer( + modifier = modifier + .clip(shape) + .then(clickModifier.backgroundModifier(state.type, state.backgroundUM, shape)), + contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), + content = { + EarnBlockIcon( + type = state.type, + iconUM = state.iconUM, + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + ) + + Text( + text = state.titleUM.text.resolveReference(), + style = state.titleUM.style.textStyle, + color = state.titleUM.tone.color(state.type), + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .padding(end = TangemTheme.dimens2.x2), + ) + + val subtitle = state.subtitleUM + if (subtitle is EarnBlockUM.SubtitleUM.Text) { + EarnBlockSubtitle( + subtitle = subtitle, + type = state.type, + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .padding(end = TangemTheme.dimens2.x2), + ) + } + + EarnBlockTrailing(type = state.type, trailingUM = state.trailingUM, onClick = state.onClick) + }, + ) +} + +@Composable +private fun Modifier.backgroundModifier( + type: Type, + backgroundUM: EarnBlockUM.BackgroundUM, + shape: RoundedCornerShape, +): Modifier { + return when (backgroundUM) { + is EarnBlockUM.BackgroundUM.Surface -> this + .background(TangemTheme.colors2.surface.level3) + .border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape) + is EarnBlockUM.BackgroundUM.AccentSoft -> tintedBackground(type.accentSoftTint(), shape) + is EarnBlockUM.BackgroundUM.AccentStrong -> tintedBackground(type.accentStrongTint(), shape) + } +} + +private fun Modifier.tintedBackground(tintColor: Color, shape: RoundedCornerShape): Modifier = this + .background(tintColor.copy(alpha = TINTED_BACKGROUND_ALPHA)) + .border(width = BorderWidth, color = tintColor.copy(alpha = TINTED_BORDER_ALPHA), shape = shape) + .innerShadow( + shape = shape, + shadow = Shadow( + radius = InnerShadowBlur, + color = tintColor.copy(alpha = TINTED_INNER_SHADOW_ALPHA), + offset = DpOffset.Zero, + ), + ) + +@Composable +private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, onClick: (() -> Unit)?) { + when (trailingUM) { + is EarnBlockUM.TrailingUM.Button -> { + TangemButton( + buttonUM = TangemButtonUM( + text = trailingUM.text, + type = type.buttonType(), + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + isEnabled = trailingUM.isEnabled, + onClick = onClick ?: {}, + ), + modifier = Modifier.layoutId(TangemRowLayoutId.TAIL), + ) + } + is EarnBlockUM.TrailingUM.Balance -> { + if (!trailingUM.isBalanceHidden) { + Text( + text = trailingUM.fiatValue.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + ) + Text( + text = trailingUM.cryptoValue.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), + ) + } + } + is EarnBlockUM.TrailingUM.Icon -> { + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + iconRes = trailingUM.tone.iconRes(), + tintReference = { trailingUM.tone.tint() }, + ), + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .size(TangemTheme.dimens2.x6), + ) + } + null -> Unit + } +} + +@Composable +private fun EarnBlockSubtitle(subtitle: EarnBlockUM.SubtitleUM.Text, type: Type, modifier: Modifier = Modifier) { + val textStyle = subtitle.style.textStyle + val textColor = subtitle.tone.color(type) + if (subtitle.loader == null) { + Text(text = subtitle.text.resolveReference(), style = textStyle, color = textColor, modifier = modifier) + return + } + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Text(text = subtitle.text.resolveReference(), style = textStyle, color = textColor) + Spacer(modifier = Modifier.width(3.dp)) + CircularProgressIndicator( + color = subtitle.loader.tone.color(), + strokeWidth = LoaderStrokeWidth, + strokeCap = StrokeCap.Round, + modifier = Modifier.size(LoaderSize), + ) + } +} + +@Composable +private fun EarnBlockIcon(type: Type, iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(TangemTheme.dimens2.x10), + ) { + if (iconUM is EarnBlockUM.IconUM.Glowing) { + val glowShape = RoundedCornerShape(percent = 50) + Box( + modifier = Modifier + .size(TangemTheme.dimens2.x6) + .blur(radius = TangemTheme.dimens2.x4, edgeTreatment = BlurredEdgeTreatment.Unbounded) + .background(color = type.accentGlow().copy(alpha = GLOW_ALPHA), shape = glowShape), + ) + } + val iconRes = when (iconUM) { + is EarnBlockUM.IconUM.Glowing -> iconUM.iconRes + is EarnBlockUM.IconUM.Plain -> iconUM.iconRes + } + TangemIcon( + tangemIconUM = TangemIconUM.Image(imageRes = iconRes), + modifier = Modifier.size(TangemTheme.dimens2.x10), + ) + } +} + +// region Type → theme mapping +@Composable +@ReadOnlyComposable +private fun Type.accentText(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.text.status.accent + Type.YieldSupply -> TangemTheme.colors2.text.status.positive +} + +@Composable +@ReadOnlyComposable +private fun Type.accentGlow(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.border.status.accent + Type.YieldSupply -> TangemTheme.colors2.text.status.positive +} + +@Composable +@ReadOnlyComposable +private fun Type.accentSoftTint(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.markers.backgroundTintedBlue + Type.YieldSupply -> TangemTheme.colors2.markers.backgroundTintedGreen +} + +@Composable +@ReadOnlyComposable +private fun Type.accentStrongTint(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.text.status.accent + Type.YieldSupply -> TangemTheme.colors2.text.status.positive +} + +private fun Type.buttonType(): TangemButtonType = when (this) { + Type.Staking -> TangemButtonType.Accent + Type.YieldSupply -> TangemButtonType.Positive +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.TitleUM.Tone.color(type: Type): Color = when (this) { + EarnBlockUM.TitleUM.Tone.Primary -> TangemTheme.colors2.text.neutral.primary + EarnBlockUM.TitleUM.Tone.Secondary -> TangemTheme.colors2.text.neutral.secondary + EarnBlockUM.TitleUM.Tone.Disabled -> TangemTheme.colors2.text.neutral.tertiary + EarnBlockUM.TitleUM.Tone.Accent -> type.accentText() +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.SubtitleUM.Tone.color(type: Type): Color = when (this) { + EarnBlockUM.SubtitleUM.Tone.Primary -> TangemTheme.colors2.text.neutral.primary + EarnBlockUM.SubtitleUM.Tone.Disabled -> TangemTheme.colors2.text.neutral.tertiary + EarnBlockUM.SubtitleUM.Tone.Accent -> type.accentText() +} + +private fun EarnBlockUM.TrailingUM.IconTone.iconRes(): Int = when (this) { + EarnBlockUM.TrailingUM.IconTone.Warning -> R.drawable.ic_alert_triangle_20 + EarnBlockUM.TrailingUM.IconTone.Info -> R.drawable.ic_alert_circle_red_20 +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.TrailingUM.IconTone.tint(): Color = when (this) { + EarnBlockUM.TrailingUM.IconTone.Warning -> TangemTheme.colors2.graphic.status.attention + EarnBlockUM.TrailingUM.IconTone.Info -> TangemTheme.colors2.fill.neutral.secondary +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.SubtitleUM.LoaderTone.color(): Color = when (this) { + EarnBlockUM.SubtitleUM.LoaderTone.Positive -> TangemTheme.colors2.text.status.positive + EarnBlockUM.SubtitleUM.LoaderTone.Muted -> TangemTheme.colors2.graphic.neutral.tertiaryConstant +} + +private val EarnBlockUM.TitleUM.Style.textStyle: TextStyle + @Composable + @ReadOnlyComposable + get() = when (this) { + EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + } + +private val EarnBlockUM.SubtitleUM.Style.textStyle: TextStyle + @Composable + @ReadOnlyComposable + get() = when (this) { + EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + } +// endregion + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnBlock_Preview(@PreviewParameter(EarnBlockPreviewProvider::class) state: EarnBlockUM) { + TangemThemePreviewRedesign { + EarnBlock( + state = state, + modifier = Modifier.padding(TangemTheme.dimens2.x4), + ) + } +} + +private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider( + collection = listOf( + EarnBlockUM.Loading, + EarnBlockUM.Content( + type = Type.Staking, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Disabled, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.staking_notification_network_error_text), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Disabled, + ), + trailingUM = null, + ), + EarnBlockUM.Content( + type = Type.Staking, + backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.token_details_staking_block_title), + style = EarnBlockUM.TitleUM.Style.Small, + tone = EarnBlockUM.TitleUM.Tone.Accent, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stringReference("Average APR 5.24%"), + style = EarnBlockUM.SubtitleUM.Style.Large, + tone = EarnBlockUM.SubtitleUM.Tone.Primary, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + text = stringReference("Stake"), + ), + onClick = {}, + ), + EarnBlockUM.Content( + type = Type.Staking, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40), + titleUM = EarnBlockUM.TitleUM( + text = stringReference("Native staking"), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stringReference("$ 12.34 rewards"), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Accent, + ), + trailingUM = EarnBlockUM.TrailingUM.Balance( + fiatValue = stringReference("$ 500.17"), + cryptoValue = stringReference("500.00 SOL"), + isBalanceHidden = false, + ), + onClick = {}, + ), + EarnBlockUM.Content( + type = Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40), + titleUM = EarnBlockUM.TitleUM( + text = stringReference("Earn yield"), + style = EarnBlockUM.TitleUM.Style.Small, + tone = EarnBlockUM.TitleUM.Tone.Accent, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stringReference("Start earning · 5.24%"), + style = EarnBlockUM.SubtitleUM.Style.Large, + tone = EarnBlockUM.SubtitleUM.Tone.Primary, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + text = stringReference("More"), + ), + onClick = {}, + ), + ), +) +// endregion \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt new file mode 100644 index 0000000000..af4ce23046 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt @@ -0,0 +1,82 @@ +package com.tangem.common.ui.earn + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface EarnBlockUM { + + data object Loading : EarnBlockUM + + data class Content( + val type: Type, + val backgroundUM: BackgroundUM, + val iconUM: IconUM, + val titleUM: TitleUM, + val subtitleUM: SubtitleUM?, + val trailingUM: TrailingUM?, + val onClick: (() -> Unit)? = null, + ) : EarnBlockUM + + enum class Type { Staking, YieldSupply } + + @Immutable + sealed interface BackgroundUM { + data object Surface : BackgroundUM + data object AccentSoft : BackgroundUM + data object AccentStrong : BackgroundUM + } + + @Immutable + sealed interface IconUM { + data class Glowing(@DrawableRes val iconRes: Int) : IconUM + data class Plain(@DrawableRes val iconRes: Int) : IconUM + } + + @Immutable + data class TitleUM( + val text: TextReference, + val style: Style, + val tone: Tone, + ) { + enum class Style { Large, Small } + enum class Tone { Primary, Secondary, Disabled, Accent } + } + + @Immutable + sealed interface SubtitleUM { + data class Text( + val text: TextReference, + val style: Style, + val tone: Tone, + val loader: Loader? = null, + ) : SubtitleUM + + data class Loader(val tone: LoaderTone) + + enum class Style { Large, Small } + enum class Tone { Primary, Disabled, Accent } + enum class LoaderTone { Positive, Muted } + } + + @Immutable + sealed interface TrailingUM { + data class Button( + val text: TextReference, + val isEnabled: Boolean = true, + ) : TrailingUM + + data class Balance( + val fiatValue: TextReference, + val cryptoValue: TextReference, + val isBalanceHidden: Boolean, + ) : TrailingUM + + data class Icon( + val tone: IconTone, + ) : TrailingUM + + enum class IconTone { Warning, Info } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt index 25b2c3389d..88a40df4bd 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt @@ -13,21 +13,25 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.constraintlayout.compose.* import com.tangem.common.ui.R +import com.tangem.common.ui.expressStatus.state.buildExpressStatusSubtitle import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.isNullOrEmpty import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenDetailsScreenTestTags @Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod", "LongParameterList") @Composable @@ -41,6 +45,7 @@ internal fun ExpressStatusItem( onClick: () -> Unit, modifier: Modifier = Modifier, toAmount: TextReference = TextReference.EMPTY, + subtitle: TextReference = TextReference.EMPTY, @DrawableRes infoIconRes: Int? = null, infoIconTint: Color? = null, ) { @@ -50,18 +55,38 @@ internal fun ExpressStatusItem( .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.primary) .clickable { onClick() } - .padding(TangemTheme.dimens.spacing12), + .padding(TangemTheme.dimens.spacing12) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM), ) { - val (titleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = createRefs() + val (titleRef, subtitleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = + createRefs() val padding6 = TangemTheme.dimens.spacing6 Text( text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, - modifier = Modifier.constrainAs(titleRef) { + modifier = Modifier + .constrainAs(titleRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + end.linkTo(infoIconRef.start, padding6, padding6) + width = Dimension.preferredWrapContent + horizontalBias = 0f + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE), + ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.constrainAs(subtitleRef) { start.linkTo(parent.start) - top.linkTo(parent.top) + top.linkTo(titleRef.bottom) + end.linkTo(infoIconRef.start, padding6, padding6) + width = Dimension.preferredWrapContent + horizontalBias = 0f + visibility = if (subtitle.isNullOrEmpty()) Visibility.Gone else Visibility.Visible }, ) CurrencyIcon( @@ -71,22 +96,25 @@ internal fun ExpressStatusItem( .size(TangemTheme.dimens.size20) .constrainAs(fromIconRef) { start.linkTo(parent.start) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON), ) EllipsisText( text = fromAmount.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.primary1, ellipsis = TextEllipsis.OffsetEnd(fromSymbol.length), - modifier = Modifier.constrainAs(fromRef) { - start.linkTo(fromIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) - end.linkTo(swapIconRef.start) - bottom.linkTo(parent.bottom) - width = Dimension.fillToConstraints.atMostWrapContent - }, + modifier = Modifier + .constrainAs(fromRef) { + start.linkTo(fromIconRef.end, padding6) + top.linkTo(subtitleRef.bottom, padding6) + end.linkTo(swapIconRef.start) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atMostWrapContent + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT), ) Icon( painter = painterResource(id = R.drawable.ic_forward_24), @@ -96,10 +124,11 @@ internal fun ExpressStatusItem( .size(TangemTheme.dimens.size12) .constrainAs(swapIconRef) { start.linkTo(fromRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) end.linkTo(toIconRef.start) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON), ) CurrencyIcon( state = toTokenIconState, @@ -108,23 +137,26 @@ internal fun ExpressStatusItem( .size(TangemTheme.dimens.size20) .constrainAs(toIconRef) { start.linkTo(swapIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) end.linkTo(toRef.start) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON), ) EllipsisText( text = toAmount.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.primary1, ellipsis = TextEllipsis.OffsetEnd(toSymbol.length), - modifier = Modifier.constrainAs(toRef) { - start.linkTo(toIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) - end.linkTo(infoIconRef.start, padding6, padding6) - bottom.linkTo(parent.bottom) - width = Dimension.fillToConstraints.atLeastWrapContent - }, + modifier = Modifier + .constrainAs(toRef) { + start.linkTo(toIconRef.end, padding6) + top.linkTo(subtitleRef.bottom, padding6) + end.linkTo(infoIconRef.start, padding6, padding6) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atLeastWrapContent + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT), ) Icon( painter = painterResource(id = infoIconRes ?: R.drawable.ic_alert_triangle_20), @@ -153,7 +185,8 @@ internal fun ExpressStatusItem( end.linkTo(parent.end) top.linkTo(parent.top) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON), ) } } @@ -167,13 +200,17 @@ private fun ExpressStatusItemPreview( ) { TangemThemePreview { ExpressStatusItem( - title = stringReference("ChangeNow"), + title = stringReference("Exchange by ChangeHero"), fromTokenIconState = CurrencyIconState.Loading, toTokenIconState = CurrencyIconState.Loading, fromAmount = stringReference(amount), fromSymbol = "USDT", toAmount = stringReference(amount), toSymbol = "USDT", + subtitle = buildExpressStatusSubtitle( + activeStatus = stringReference("Confirming"), + date = stringReference("59 min ago"), + ), onClick = {}, infoIconRes = null, infoIconTint = null, diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt index 1424805a32..49d77a4633 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt @@ -14,10 +14,10 @@ fun LazyListScope.expressTransactionsItems( ) { items( count = expressTxs.size, - key = { expressTxs[it].info.txId }, - contentType = { expressTxs[it]::class.java }, - ) { - val itemInfo = expressTxs[it].info + key = { index -> expressTxs[index].info.txId }, + contentType = { index -> expressTxs[index]::class.java }, + ) { index -> + val itemInfo = expressTxs[index].info val (iconRes, tint) = when (itemInfo.iconState) { ExpressTransactionStateIconUM.Warning -> { R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention @@ -36,6 +36,7 @@ fun LazyListScope.expressTransactionsItems( fromSymbol = itemInfo.fromAmountSymbol, toAmount = itemInfo.toAmount, toSymbol = itemInfo.toAmountSymbol, + subtitle = itemInfo.subtitle, onClick = itemInfo.onClick, infoIconRes = iconRes, infoIconTint = tint, diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/OnrampStatusMapper.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/OnrampStatusMapper.kt new file mode 100644 index 0000000000..a1e1b27b08 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/OnrampStatusMapper.kt @@ -0,0 +1,38 @@ +package com.tangem.common.ui.expressStatus + +import com.tangem.common.ui.R +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.onramp.model.OnrampStatus + +fun OnrampStatus.Status.toActiveStatusText(currencyName: String): TextReference = when (this) { + OnrampStatus.Status.Created, + OnrampStatus.Status.WaitingForPayment, + -> resourceReference(R.string.express_exchange_status_receiving_active) + OnrampStatus.Status.PaymentProcessing -> resourceReference(R.string.express_exchange_status_confirming_active) + OnrampStatus.Status.Verifying -> resourceReference(R.string.express_exchange_status_verifying) + OnrampStatus.Status.Paid -> resourceReference(R.string.express_status_buying_active, wrappedList(currencyName)) + OnrampStatus.Status.Sending -> resourceReference( + R.string.express_exchange_status_sending_active, + wrappedList(currencyName), + ) + OnrampStatus.Status.Finished -> resourceReference(R.string.express_status_bought, wrappedList(currencyName)) + OnrampStatus.Status.RefundInProgress -> resourceReference(R.string.express_exchange_status_refunding) + OnrampStatus.Status.Refunded -> resourceReference(R.string.express_exchange_status_refunded) + OnrampStatus.Status.Paused -> resourceReference(R.string.express_exchange_status_paused) + OnrampStatus.Status.Expired, + OnrampStatus.Status.Failed, + -> resourceReference(R.string.express_exchange_status_failed) +} + +fun OnrampStatus.Status.toIconState(): ExpressTransactionStateIconUM = when (this) { + OnrampStatus.Status.Verifying, + OnrampStatus.Status.RefundInProgress, + -> ExpressTransactionStateIconUM.Warning + OnrampStatus.Status.Refunded, + OnrampStatus.Status.Failed, + -> ExpressTransactionStateIconUM.Error + else -> ExpressTransactionStateIconUM.None +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilder.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilder.kt new file mode 100644 index 0000000000..dadfe910b1 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilder.kt @@ -0,0 +1,52 @@ +package com.tangem.common.ui.expressStatus.state + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.isNullOrEmpty +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList + +/** + * Builds express status subtitle by combining [activeStatus] with formatted [date]. + * + * Separator rules: + * - Relative date (MinutesAgo / HoursAgo PluralRes) — " ~ ". + * - Absolute date (Today / FullDate) — " "; Today's leading Res is decapitalized when a status + * prefix is present. + * - Either side empty — the other is returned as is (no modifications). + */ +fun buildExpressStatusSubtitle(activeStatus: TextReference, date: TextReference): TextReference { + val hasStatus = !activeStatus.isNullOrEmpty() + val hasDate = !date.isNullOrEmpty() + return when { + !hasStatus && !hasDate -> TextReference.EMPTY + hasStatus && !hasDate -> activeStatus + !hasStatus && hasDate -> date + else -> combineWithStatus(activeStatus, date) + } +} + +private fun combineWithStatus(status: TextReference, date: TextReference): TextReference { + val shouldUseTilde = date.isRelativeTimeAgo() + val separator = if (shouldUseTilde) stringReference(value = " ~ ") else stringReference(value = " ") + val datePart = if (shouldUseTilde) date else date.decapitalizeToday() + return TextReference.Combined(refs = wrappedList(status, separator, datePart)) +} + +private fun TextReference.isRelativeTimeAgo(): Boolean { + return this is TextReference.PluralRes && + (id == R.plurals.common_minutes_time_ago || id == R.plurals.common_hours_time_ago) +} + +private fun TextReference.decapitalizeToday(): TextReference { + if (this !is TextReference.Combined) return this + val patched = refs.data.map { ref -> + if (ref is TextReference.Res && ref.id == R.string.common_today) { + ref.copy(shouldDecapitalize = true) + } else { + ref + } + } + return TextReference.Combined(refs = WrappedList(data = patched)) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt index fb2489e331..2b7035b44d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt @@ -28,6 +28,8 @@ data class ExpressTransactionStateInfoUM( val txExternalUrl: String?, val timestamp: Long, val timestampFormatted: TextReference, + val timestampAgoFormatted: TextReference, + val activeStatus: TextReference, val onGoToProviderClick: (String) -> Unit, val onClick: () -> Unit, val onDisposeExpressStatus: () -> Unit, @@ -36,12 +38,14 @@ data class ExpressTransactionStateInfoUM( val toFiatAmount: TextReference?, val toAmountSymbol: String, val toCurrencyIcon: CurrencyIconState, - val fromAmount: TextReference, val fromFiatAmount: TextReference?, val fromAmountSymbol: String, val fromCurrencyIcon: CurrencyIconState, -) +) { + val subtitle: TextReference + get() = buildExpressStatusSubtitle(activeStatus = activeStatus, date = timestampAgoFormatted) +} enum class ExpressTransactionStateIconUM { Warning, diff --git a/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt new file mode 100644 index 0000000000..4b128eff63 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt @@ -0,0 +1,311 @@ +package com.tangem.common.ui.extensions + +import androidx.annotation.DrawableRes +import com.tangem.blockchain.common.Blockchain +import com.tangem.core.ui.R + +/** + * Holds icon resources for a single [Blockchain]. + * + * @property active drawable for the active state + * @property greyedOut drawable for the disabled / greyed-out state + */ +private data class IconSet( + @DrawableRes val active: Int, + @DrawableRes val greyedOut: Int, +) + +/** + * Returns the [IconSet] for the given [blockchain], or `null` if icons are not yet available. + * + * The `when` is exhaustive — adding a new [Blockchain] entry in the SDK without handling it here + * will cause a compile-time error. + */ +@Suppress("CyclomaticComplexMethod", "LongMethod") +private fun iconSetOf(blockchain: Blockchain): IconSet? = when (blockchain) { + Blockchain.Alephium, + Blockchain.AlephiumTestnet, + -> IconSet(active = R.drawable.img_alephium_22, greyedOut = R.drawable.ic_alephium_22) + Blockchain.AlephZero, + Blockchain.AlephZeroTestnet, + -> IconSet(active = R.drawable.img_azero_22, greyedOut = R.drawable.ic_azero_22) + Blockchain.Algorand, + Blockchain.AlgorandTestnet, + -> IconSet(active = R.drawable.img_algorand_22, greyedOut = R.drawable.ic_algorand_22) + Blockchain.ApeChain, + Blockchain.ApeChainTestnet, + -> IconSet(active = R.drawable.img_apecoin_22, greyedOut = R.drawable.ic_apecoin_22) + Blockchain.Aptos, + Blockchain.AptosTestnet, + -> IconSet(active = R.drawable.img_aptos_22, greyedOut = R.drawable.ic_aptos_22) + Blockchain.Arbitrum, + Blockchain.ArbitrumTestnet, + -> IconSet(active = R.drawable.img_arbitrum_22, greyedOut = R.drawable.ic_arbitrum_22) + Blockchain.ArbitrumNova, + -> IconSet(active = R.drawable.img_arbitrum_nova_22, greyedOut = R.drawable.ic_arbitrum_nova_22) + Blockchain.Areon, + Blockchain.AreonTestnet, + -> IconSet(active = R.drawable.img_areon_22, greyedOut = R.drawable.ic_areon_22) + Blockchain.Aurora, + Blockchain.AuroraTestnet, + -> IconSet(active = R.drawable.img_aurora_22, greyedOut = R.drawable.ic_aurora_22) + Blockchain.Avalanche, + Blockchain.AvalancheTestnet, + -> IconSet(active = R.drawable.img_avalanche_22, greyedOut = R.drawable.ic_avalanche_22) + Blockchain.BSC, + Blockchain.BSCTestnet, + Blockchain.Binance, + Blockchain.BinanceTestnet, + -> IconSet(active = R.drawable.img_bsc_22, greyedOut = R.drawable.ic_bsc_16) + Blockchain.Base, + Blockchain.BaseTestnet, + -> IconSet(active = R.drawable.img_base_22, greyedOut = R.drawable.ic_base_22) + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + -> IconSet(active = R.drawable.img_btc_22, greyedOut = R.drawable.ic_bitcoin_16) + Blockchain.BitcoinCash, + Blockchain.BitcoinCashTestnet, + -> IconSet(active = R.drawable.img_btc_cash_22, greyedOut = R.drawable.ic_bitcoin_cash_16) + Blockchain.Bitrock, + Blockchain.BitrockTestnet, + -> IconSet(active = R.drawable.img_bitrock_22, greyedOut = R.drawable.ic_bitrock_22) + Blockchain.Bittensor, + -> IconSet(active = R.drawable.img_bittensor_22, greyedOut = R.drawable.ic_bittensor_22) + Blockchain.Blast, + Blockchain.BlastTestnet, + -> IconSet(active = R.drawable.img_blast_22, greyedOut = R.drawable.ic_blast_22) + Blockchain.Canxium, + -> IconSet(active = R.drawable.img_canxium_22, greyedOut = R.drawable.ic_canxium_22) + Blockchain.Cardano, + -> IconSet(active = R.drawable.img_cardano_22, greyedOut = R.drawable.ic_cardano_16) + Blockchain.Casper, + Blockchain.CasperTestnet, + -> IconSet(active = R.drawable.img_casper_22, greyedOut = R.drawable.ic_casper_22) + Blockchain.Chia, + Blockchain.ChiaTestnet, + -> IconSet(active = R.drawable.img_chia_22, greyedOut = R.drawable.ic_chia_22) + Blockchain.Chiliz, + Blockchain.ChilizTestnet, + -> IconSet(active = R.drawable.img_chiliz_22, greyedOut = R.drawable.ic_chiliz_22) + Blockchain.Clore, + -> IconSet(active = R.drawable.img_clore_22, greyedOut = R.drawable.ic_clore_22) + Blockchain.Core, + Blockchain.CoreTestnet, + -> IconSet(active = R.drawable.img_core_22, greyedOut = R.drawable.ic_core_22) + Blockchain.Cosmos, + Blockchain.CosmosTestnet, + -> IconSet(active = R.drawable.img_cosmos_22, greyedOut = R.drawable.ic_cosmos_22) + Blockchain.Cronos, + -> IconSet(active = R.drawable.img_cronos_22, greyedOut = R.drawable.ic_cronos_22) + Blockchain.Cyber, + Blockchain.CyberTestnet, + -> IconSet(active = R.drawable.img_cyber_22, greyedOut = R.drawable.ic_cyber_22) + Blockchain.Dash, + -> IconSet(active = R.drawable.img_dash_22, greyedOut = R.drawable.ic_dash_22) + Blockchain.Decimal, + Blockchain.DecimalTestnet, + -> IconSet(active = R.drawable.img_decimal_22, greyedOut = R.drawable.ic_decimal_22) + Blockchain.Dischain, + -> IconSet(active = R.drawable.img_dischain_22, greyedOut = R.drawable.ic_dischain_22) + Blockchain.Dogecoin, + -> IconSet(active = R.drawable.img_dogecoin_22, greyedOut = R.drawable.ic_dogecoin_16) + Blockchain.Ducatus, + -> IconSet(active = R.drawable.img_ducatus_22, greyedOut = R.drawable.ic_ducatus_22) + Blockchain.EnergyWebChain, + Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, + Blockchain.EnergyWebXTestnet, + -> IconSet(active = R.drawable.img_energy_web_22, greyedOut = R.drawable.ic_energy_web_22) + Blockchain.Ethereum, + Blockchain.EthereumTestnet, + -> IconSet(active = R.drawable.img_eth_22, greyedOut = R.drawable.ic_eth_16) + Blockchain.EthereumClassic, + Blockchain.EthereumClassicTestnet, + -> IconSet(active = R.drawable.img_eth_classic_22, greyedOut = R.drawable.ic_eth_16) + Blockchain.EthereumPow, + Blockchain.EthereumPowTestnet, + -> IconSet(active = R.drawable.img_eth_pow_22, greyedOut = R.drawable.ic_ethereumpow_22) + Blockchain.Fact0rn, + -> IconSet(active = R.drawable.img_fact0rn_22, greyedOut = R.drawable.ic_fact0rn_22) + Blockchain.Fantom, + Blockchain.FantomTestnet, + -> IconSet(active = R.drawable.img_fantom_22, greyedOut = R.drawable.ic_fantom_22) + Blockchain.Filecoin, + -> IconSet(active = R.drawable.img_filecoin_22, greyedOut = R.drawable.ic_filecoin_22) + Blockchain.Flare, + Blockchain.FlareTestnet, + -> IconSet(active = R.drawable.img_flare_22, greyedOut = R.drawable.ic_flare_22) + Blockchain.Gnosis, + -> IconSet(active = R.drawable.img_gnosis_22, greyedOut = R.drawable.ic_gnosis_22) + Blockchain.Hedera, + Blockchain.HederaTestnet, + -> IconSet(active = R.drawable.img_hedera_22, greyedOut = R.drawable.ic_hedera_22) + Blockchain.Hyperliquid, + Blockchain.HyperliquidTestnet, + -> IconSet(active = R.drawable.img_hyperliquid_22, greyedOut = R.drawable.ic_hyperliquid_22) + Blockchain.InternetComputer, + -> IconSet(active = R.drawable.img_icp_22, greyedOut = R.drawable.ic_icp_22) + Blockchain.Joystream, + -> IconSet(active = R.drawable.img_joystream_22, greyedOut = R.drawable.ic_joystream_22) + Blockchain.Kaspa, + Blockchain.KaspaTestnet, + -> IconSet(active = R.drawable.img_kaspa_22, greyedOut = R.drawable.ic_kaspa_22) + Blockchain.Kava, + Blockchain.KavaTestnet, + -> IconSet(active = R.drawable.img_kava_22, greyedOut = R.drawable.ic_kava_22) + Blockchain.Koinos, + Blockchain.KoinosTestnet, + -> IconSet(active = R.drawable.img_koinos_22, greyedOut = R.drawable.ic_koinos_22) + Blockchain.Kusama, + -> IconSet(active = R.drawable.img_kusama_22, greyedOut = R.drawable.ic_kusama_16) + Blockchain.Linea, + Blockchain.LineaTestnet, + -> IconSet(active = R.drawable.img_linea_22, greyedOut = R.drawable.ic_linea_22) + Blockchain.Litecoin, + -> IconSet(active = R.drawable.img_litecoin_22, greyedOut = R.drawable.ic_litecoin_22) + Blockchain.Manta, + Blockchain.MantaTestnet, + -> IconSet(active = R.drawable.img_manta_22, greyedOut = R.drawable.ic_manta_22) + Blockchain.Mantle, + Blockchain.MantleTestnet, + -> IconSet(active = R.drawable.img_mantle_22, greyedOut = R.drawable.ic_mantle_22) + Blockchain.Monad, + Blockchain.MonadTestnet, + -> IconSet(active = R.drawable.img_monad_22, greyedOut = R.drawable.ic_monad_22) + Blockchain.Moonbeam, + Blockchain.MoonbeamTestnet, + -> IconSet(active = R.drawable.img_moonbeam_22, greyedOut = R.drawable.ic_moonbeam_22) + Blockchain.Moonriver, + Blockchain.MoonriverTestnet, + -> IconSet(active = R.drawable.img_moonriver_22, greyedOut = R.drawable.ic_moonriver_22) + Blockchain.Near, + Blockchain.NearTestnet, + -> IconSet(active = R.drawable.img_near_22, greyedOut = R.drawable.ic_near_22) + Blockchain.OctaSpace, + Blockchain.OctaSpaceTestnet, + -> IconSet(active = R.drawable.img_octaspace_22, greyedOut = R.drawable.ic_octaspace_22) + Blockchain.OdysseyChain, + Blockchain.OdysseyChainTestnet, + -> IconSet(active = R.drawable.img_odyssey_chain_22, greyedOut = R.drawable.ic_odyssey_chain_22) + Blockchain.Optimism, + Blockchain.OptimismTestnet, + -> IconSet(active = R.drawable.img_optimism_22, greyedOut = R.drawable.ic_optimism_22) + Blockchain.Pepecoin, + Blockchain.PepecoinTestnet, + -> IconSet(active = R.drawable.img_pepecoin_22, greyedOut = R.drawable.ic_pepecoin_22) + Blockchain.Plasma, + Blockchain.PlasmaTestnet, + -> IconSet(active = R.drawable.img_plasma_22, greyedOut = R.drawable.ic_plasma_22) + Blockchain.Playa3ull, + -> IconSet(active = R.drawable.img_playa3ull_22, greyedOut = R.drawable.ic_playa3ull_22) + Blockchain.Polkadot, + Blockchain.PolkadotTestnet, + -> IconSet(active = R.drawable.img_polkadot_22, greyedOut = R.drawable.ic_polkadot_16) + Blockchain.Polygon, + Blockchain.PolygonTestnet, + -> IconSet(active = R.drawable.img_polygon_22, greyedOut = R.drawable.ic_polygon_22) + Blockchain.PolygonZkEVM, + Blockchain.PolygonZkEVMTestnet, + -> IconSet(active = R.drawable.img_polygon_22, greyedOut = R.drawable.ic_polygon_22) + Blockchain.PulseChain, + Blockchain.PulseChainTestnet, + -> IconSet(active = R.drawable.img_pls_22, greyedOut = R.drawable.ic_pls_22) + Blockchain.Quai, + Blockchain.QuaiTestnet, + -> IconSet(active = R.drawable.img_quai_22, greyedOut = R.drawable.ic_quai_22) + Blockchain.RSK, + -> IconSet(active = R.drawable.img_rsk_22, greyedOut = R.drawable.ic_rsk_16) + Blockchain.Radiant, + -> IconSet(active = R.drawable.img_radiant_22, greyedOut = R.drawable.ic_radiant_22) + Blockchain.Ravencoin, + Blockchain.RavencoinTestnet, + -> IconSet(active = R.drawable.img_ravencoin_22, greyedOut = R.drawable.ic_ravencoin_22) + Blockchain.Scroll, + Blockchain.ScrollTestnet, + -> IconSet(active = R.drawable.img_scroll_22, greyedOut = R.drawable.ic_scroll_22) + Blockchain.Sei, + Blockchain.SeiTestnet, + -> IconSet(active = R.drawable.img_sei_22, greyedOut = R.drawable.ic_sei_22) + Blockchain.Shibarium, + Blockchain.ShibariumTestnet, + -> IconSet(active = R.drawable.img_shibarium_22, greyedOut = R.drawable.ic_shibarium_22) + Blockchain.Solana, + Blockchain.SolanaTestnet, + -> IconSet(active = R.drawable.img_solana_22, greyedOut = R.drawable.ic_solana_16) + Blockchain.Sonic, + Blockchain.SonicTestnet, + -> IconSet(active = R.drawable.img_sonic_22, greyedOut = R.drawable.ic_sonic_22) + Blockchain.Stellar, + Blockchain.StellarTestnet, + -> IconSet(active = R.drawable.img_stellar_22, greyedOut = R.drawable.ic_stellar_16) + Blockchain.Sui, + Blockchain.SuiTestnet, + -> IconSet(active = R.drawable.img_sui_22, greyedOut = R.drawable.ic_sui_22) + Blockchain.TON, + Blockchain.TONTestnet, + -> IconSet(active = R.drawable.img_ton_22, greyedOut = R.drawable.ic_ton_22) + Blockchain.Taraxa, + Blockchain.TaraxaTestnet, + -> IconSet(active = R.drawable.img_taraxa_22, greyedOut = R.drawable.ic_taraxa_22) + Blockchain.Telos, + Blockchain.TelosTestnet, + -> IconSet(active = R.drawable.img_telos_22, greyedOut = R.drawable.ic_telos_22) + Blockchain.TerraV1, + -> IconSet(active = R.drawable.img_terra_22, greyedOut = R.drawable.ic_terra_22) + Blockchain.TerraV2, + -> IconSet(active = R.drawable.img_terra2_22, greyedOut = R.drawable.ic_terra2_22) + Blockchain.Tezos, + -> IconSet(active = R.drawable.img_tezos_22, greyedOut = R.drawable.ic_tezos_16) + Blockchain.Tron, + Blockchain.TronTestnet, + -> IconSet(active = R.drawable.img_tron_22, greyedOut = R.drawable.ic_tron_22) + Blockchain.VanarChain, + Blockchain.VanarChainTestnet, + -> IconSet(active = R.drawable.img_vanar_22, greyedOut = R.drawable.ic_vanar_22) + Blockchain.VeChain, + Blockchain.VeChainTestnet, + -> IconSet(active = R.drawable.img_vechain_22, greyedOut = R.drawable.ic_vechain_22) + Blockchain.XDC, + Blockchain.XDCTestnet, + -> IconSet(active = R.drawable.img_xdc_22, greyedOut = R.drawable.ic_xdc_22) + Blockchain.XRP, + -> IconSet(active = R.drawable.img_xrp_22, greyedOut = R.drawable.ic_xrp_22) + Blockchain.Xodex, + -> IconSet(active = R.drawable.img_xodex_22, greyedOut = R.drawable.ic_xodex_22) + Blockchain.ZkLinkNova, + Blockchain.ZkLinkNovaTestnet, + -> IconSet(active = R.drawable.img_zklink_22, greyedOut = R.drawable.ic_zklink_22) + Blockchain.ZkSyncEra, + Blockchain.ZkSyncEraTestnet, + -> IconSet(active = R.drawable.img_zksync_22, greyedOut = R.drawable.ic_zksync_22) + Blockchain.Nexa, + Blockchain.NexaTestnet, + Blockchain.Unknown, + -> null +} + +private val ICONS_BY_BLOCKCHAIN: Map = Blockchain.entries + .mapNotNull { blockchain -> iconSetOf(blockchain)?.let { blockchain to it } } + .toMap() + +/** + * Returns the active (colored) icon drawable resource for the given [blockchain]. + * + * @param blockchain the blockchain to look up + * @param fallback drawable returned when [blockchain] has no icon defined + */ +@DrawableRes +fun getActiveIconRes(blockchain: Blockchain, @DrawableRes fallback: Int = R.drawable.ic_alert_24): Int { + return ICONS_BY_BLOCKCHAIN[blockchain]?.active ?: fallback +} + +/** + * Returns the greyed-out (disabled) icon drawable resource for the given [blockchain]. + * + * @param blockchain the blockchain to look up + * @param fallback drawable returned when [blockchain] has no icon defined + */ +@DrawableRes +fun getGreyedOutIconRes(blockchain: Blockchain, @DrawableRes fallback: Int = R.drawable.ic_alert_24): Int { + return ICONS_BY_BLOCKCHAIN[blockchain]?.greyedOut ?: fallback +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/extensions/NetworkIconExt.kt b/common/ui/src/main/java/com/tangem/common/ui/extensions/NetworkIconExt.kt new file mode 100644 index 0000000000..3162bb957e --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/extensions/NetworkIconExt.kt @@ -0,0 +1,62 @@ +package com.tangem.common.ui.extensions + +import androidx.annotation.DrawableRes +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network + +/** + * Retrieves the active icon drawable resource for the network of a [CryptoCurrency]. + */ +@get:DrawableRes +val CryptoCurrency.networkIconResId: Int + get() = network.iconResId + +/** + * Retrieves the greyed-out icon drawable resource for the network of a [CryptoCurrency]. + */ +@get:DrawableRes +val CryptoCurrency.networkGreyedOutIconResId: Int + get() = network.greyedOutIconResId + +/** + * Retrieves the active icon drawable resource for this [Network]. + */ +@get:DrawableRes +val Network.iconResId: Int + get() = id.iconResId + +/** + * Retrieves the greyed-out icon drawable resource for this [Network]. + */ +@get:DrawableRes +val Network.greyedOutIconResId: Int + get() = id.greyedOutIconResId + +/** + * Retrieves the active icon drawable resource for this [Network.ID]. + */ +@get:DrawableRes +val Network.ID.iconResId: Int + get() = rawId.iconResId + +/** + * Retrieves the greyed-out icon drawable resource for this [Network.ID]. + */ +@get:DrawableRes +val Network.ID.greyedOutIconResId: Int + get() = rawId.greyedOutIconResId + +/** + * Retrieves the active icon drawable resource for this [Network.RawID]. + */ +@get:DrawableRes +val Network.RawID.iconResId: Int + get() = getActiveIconRes(toBlockchain()) + +/** + * Retrieves the greyed-out icon drawable resource for this [Network.RawID]. + */ +@get:DrawableRes +val Network.RawID.greyedOutIconResId: Int + get() = getGreyedOutIconRes(toBlockchain()) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt index 8b1bebbcd4..182696bedf 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt @@ -1,8 +1,10 @@ package com.tangem.common.ui.navigationButtons import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference +@Immutable sealed class NavigationButtonsState { data object Empty : NavigationButtonsState() diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index c2ea0c7fe3..4e0e7d90e5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -4,7 +4,7 @@ import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountFieldModel import com.tangem.common.ui.amountScreen.utils.getFiatString -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.uncapped diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt index e6541f20cb..be987dde2a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.tokenlist.TokenList sealed interface TokenConverterParams { /** Wallet mode; list of tokens for main account */ data class Wallet( - val mainAccount: AccountStatus, + val mainAccount: AccountStatus.CryptoPortfolio, val tokenList: TokenList, ) : TokenConverterParams diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index da7e2748eb..b350ff74b9 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -4,7 +4,7 @@ import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount import com.tangem.common.ui.R import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilderTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilderTest.kt new file mode 100644 index 0000000000..04870d3d3b --- /dev/null +++ b/common/ui/src/test/kotlin/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilderTest.kt @@ -0,0 +1,117 @@ +package com.tangem.common.ui.expressStatus.state + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.utils.StringsSigns +import org.junit.jupiter.api.Test + +internal class ExpressStatusSubtitleBuilderTest { + + private val status = stringReference("Confirming") + + private val minutesAgo = TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 30, + formatArgs = wrappedList(30), + ) + + private val hoursAgo = TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = 3, + formatArgs = wrappedList(3), + ) + + private val today = TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ) + + private val fullDate = TextReference.Str("14 Oct 2025") + + @Test + fun `GIVEN empty activeStatus AND empty date WHEN buildExpressStatusSubtitle THEN return EMPTY`() { + val result = buildExpressStatusSubtitle( + activeStatus = TextReference.EMPTY, + date = TextReference.EMPTY, + ) + + assertThat(result).isEqualTo(TextReference.EMPTY) + } + + @Test + fun `GIVEN non-empty activeStatus AND empty date WHEN buildExpressStatusSubtitle THEN return activeStatus`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = TextReference.EMPTY) + + assertThat(result).isEqualTo(status) + } + + @Test + fun `GIVEN empty activeStatus AND MinutesAgo date WHEN buildExpressStatusSubtitle THEN return date as is`() { + val result = buildExpressStatusSubtitle(activeStatus = TextReference.EMPTY, date = minutesAgo) + + assertThat(result).isEqualTo(minutesAgo) + } + + @Test + fun `GIVEN status AND MinutesAgo date WHEN buildExpressStatusSubtitle THEN return Combined with tilde separator`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = minutesAgo) + + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" ~ "), minutesAgo)), + ) + } + + @Test + fun `GIVEN status AND HoursAgo date WHEN buildExpressStatusSubtitle THEN return Combined with tilde separator`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = hoursAgo) + + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" ~ "), hoursAgo)), + ) + } + + @Test + fun `GIVEN status AND Today date WHEN buildExpressStatusSubtitle THEN return Combined with space AND decapitalized today`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = today) + + val expectedToday = TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(id = R.string.common_today, shouldDecapitalize = true), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ) + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" "), expectedToday)), + ) + } + + @Test + fun `GIVEN status AND FullDate date WHEN buildExpressStatusSubtitle THEN return Combined with space separator`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = fullDate) + + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" "), fullDate)), + ) + } + + @Test + fun `GIVEN empty activeStatus AND Today date WHEN buildExpressStatusSubtitle THEN return Today as is without decapitalize`() { + val result = buildExpressStatusSubtitle(activeStatus = TextReference.EMPTY, date = today) + + assertThat(result).isEqualTo(today) + } +} \ No newline at end of file diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt new file mode 100644 index 0000000000..016c6ac306 --- /dev/null +++ b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt @@ -0,0 +1,256 @@ +package com.tangem.common.ui.extensions + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.core.ui.R +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class BlockchainIconsTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetActiveIconRes { + + @ParameterizedTest + @ProvideTestModels + fun returnsCorrectDrawable(model: TestModel) { + assertThat(getActiveIconRes(model.input)).isEqualTo(model.expected) + } + + @Test + fun returnsAlertDrawableForUnknownId() { + assertThat(getActiveIconRes(Blockchain.Unknown)).isEqualTo(R.drawable.ic_alert_24) + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + private fun provideTestModels() = Blockchain.entries.map { blockchain -> + val expected = when (blockchain) { + Blockchain.Alephium, Blockchain.AlephiumTestnet -> R.drawable.img_alephium_22 + Blockchain.AlephZero, Blockchain.AlephZeroTestnet -> R.drawable.img_azero_22 + Blockchain.Algorand, Blockchain.AlgorandTestnet -> R.drawable.img_algorand_22 + Blockchain.ApeChain, Blockchain.ApeChainTestnet -> R.drawable.img_apecoin_22 + Blockchain.Aptos, Blockchain.AptosTestnet -> R.drawable.img_aptos_22 + Blockchain.Arbitrum, Blockchain.ArbitrumTestnet -> R.drawable.img_arbitrum_22 + Blockchain.ArbitrumNova -> R.drawable.img_arbitrum_nova_22 + Blockchain.Areon, Blockchain.AreonTestnet -> R.drawable.img_areon_22 + Blockchain.Aurora, Blockchain.AuroraTestnet -> R.drawable.img_aurora_22 + Blockchain.Avalanche, Blockchain.AvalancheTestnet -> R.drawable.img_avalanche_22 + Blockchain.BSC, Blockchain.BSCTestnet, + Blockchain.Binance, Blockchain.BinanceTestnet, + -> R.drawable.img_bsc_22 + Blockchain.Base, Blockchain.BaseTestnet -> R.drawable.img_base_22 + Blockchain.Bitcoin, Blockchain.BitcoinTestnet -> R.drawable.img_btc_22 + Blockchain.BitcoinCash, Blockchain.BitcoinCashTestnet -> R.drawable.img_btc_cash_22 + Blockchain.Bitrock, Blockchain.BitrockTestnet -> R.drawable.img_bitrock_22 + Blockchain.Bittensor -> R.drawable.img_bittensor_22 + Blockchain.Blast, Blockchain.BlastTestnet -> R.drawable.img_blast_22 + Blockchain.Canxium -> R.drawable.img_canxium_22 + Blockchain.Cardano -> R.drawable.img_cardano_22 + Blockchain.Casper, Blockchain.CasperTestnet -> R.drawable.img_casper_22 + Blockchain.Chia, Blockchain.ChiaTestnet -> R.drawable.img_chia_22 + Blockchain.Chiliz, Blockchain.ChilizTestnet -> R.drawable.img_chiliz_22 + Blockchain.Clore -> R.drawable.img_clore_22 + Blockchain.Core, Blockchain.CoreTestnet -> R.drawable.img_core_22 + Blockchain.Cosmos, Blockchain.CosmosTestnet -> R.drawable.img_cosmos_22 + Blockchain.Cronos -> R.drawable.img_cronos_22 + Blockchain.Cyber, Blockchain.CyberTestnet -> R.drawable.img_cyber_22 + Blockchain.Dash -> R.drawable.img_dash_22 + Blockchain.Decimal, Blockchain.DecimalTestnet -> R.drawable.img_decimal_22 + Blockchain.Dischain -> R.drawable.img_dischain_22 + Blockchain.Dogecoin -> R.drawable.img_dogecoin_22 + Blockchain.Ducatus -> R.drawable.img_ducatus_22 + Blockchain.EnergyWebChain, Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, Blockchain.EnergyWebXTestnet, + -> R.drawable.img_energy_web_22 + Blockchain.Ethereum, Blockchain.EthereumTestnet -> R.drawable.img_eth_22 + Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet -> R.drawable.img_eth_classic_22 + Blockchain.EthereumPow, Blockchain.EthereumPowTestnet -> R.drawable.img_eth_pow_22 + Blockchain.Fact0rn -> R.drawable.img_fact0rn_22 + Blockchain.Fantom, Blockchain.FantomTestnet -> R.drawable.img_fantom_22 + Blockchain.Filecoin -> R.drawable.img_filecoin_22 + Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.img_flare_22 + Blockchain.Gnosis -> R.drawable.img_gnosis_22 + Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.img_hedera_22 + Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.img_hyperliquid_22 + Blockchain.InternetComputer -> R.drawable.img_icp_22 + Blockchain.Joystream -> R.drawable.img_joystream_22 + Blockchain.Kaspa, Blockchain.KaspaTestnet -> R.drawable.img_kaspa_22 + Blockchain.Kava, Blockchain.KavaTestnet -> R.drawable.img_kava_22 + Blockchain.Koinos, Blockchain.KoinosTestnet -> R.drawable.img_koinos_22 + Blockchain.Kusama -> R.drawable.img_kusama_22 + Blockchain.Linea, Blockchain.LineaTestnet -> R.drawable.img_linea_22 + Blockchain.Litecoin -> R.drawable.img_litecoin_22 + Blockchain.Manta, Blockchain.MantaTestnet -> R.drawable.img_manta_22 + Blockchain.Mantle, Blockchain.MantleTestnet -> R.drawable.img_mantle_22 + Blockchain.Monad, Blockchain.MonadTestnet -> R.drawable.img_monad_22 + Blockchain.Moonbeam, Blockchain.MoonbeamTestnet -> R.drawable.img_moonbeam_22 + Blockchain.Moonriver, Blockchain.MoonriverTestnet -> R.drawable.img_moonriver_22 + Blockchain.Near, Blockchain.NearTestnet -> R.drawable.img_near_22 + Blockchain.OctaSpace, Blockchain.OctaSpaceTestnet -> R.drawable.img_octaspace_22 + Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet -> R.drawable.img_odyssey_chain_22 + Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.img_optimism_22 + Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> R.drawable.img_pepecoin_22 + Blockchain.Plasma, Blockchain.PlasmaTestnet -> R.drawable.img_plasma_22 + Blockchain.Playa3ull -> R.drawable.img_playa3ull_22 + Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.img_polkadot_22 + Blockchain.Polygon, Blockchain.PolygonTestnet -> R.drawable.img_polygon_22 + Blockchain.PolygonZkEVM, Blockchain.PolygonZkEVMTestnet -> R.drawable.img_polygon_22 + Blockchain.PulseChain, Blockchain.PulseChainTestnet -> R.drawable.img_pls_22 + Blockchain.Quai, Blockchain.QuaiTestnet -> R.drawable.img_quai_22 + Blockchain.RSK -> R.drawable.img_rsk_22 + Blockchain.Radiant -> R.drawable.img_radiant_22 + Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> R.drawable.img_ravencoin_22 + Blockchain.Scroll, Blockchain.ScrollTestnet -> R.drawable.img_scroll_22 + Blockchain.Sei, Blockchain.SeiTestnet -> R.drawable.img_sei_22 + Blockchain.Shibarium, Blockchain.ShibariumTestnet -> R.drawable.img_shibarium_22 + Blockchain.Solana, Blockchain.SolanaTestnet -> R.drawable.img_solana_22 + Blockchain.Sonic, Blockchain.SonicTestnet -> R.drawable.img_sonic_22 + Blockchain.Stellar, Blockchain.StellarTestnet -> R.drawable.img_stellar_22 + Blockchain.Sui, Blockchain.SuiTestnet -> R.drawable.img_sui_22 + Blockchain.TON, Blockchain.TONTestnet -> R.drawable.img_ton_22 + Blockchain.Taraxa, Blockchain.TaraxaTestnet -> R.drawable.img_taraxa_22 + Blockchain.Telos, Blockchain.TelosTestnet -> R.drawable.img_telos_22 + Blockchain.TerraV1 -> R.drawable.img_terra_22 + Blockchain.TerraV2 -> R.drawable.img_terra2_22 + Blockchain.Tezos -> R.drawable.img_tezos_22 + Blockchain.Tron, Blockchain.TronTestnet -> R.drawable.img_tron_22 + Blockchain.VanarChain, Blockchain.VanarChainTestnet -> R.drawable.img_vanar_22 + Blockchain.VeChain, Blockchain.VeChainTestnet -> R.drawable.img_vechain_22 + Blockchain.XDC, Blockchain.XDCTestnet -> R.drawable.img_xdc_22 + Blockchain.XRP -> R.drawable.img_xrp_22 + Blockchain.Xodex -> R.drawable.img_xodex_22 + Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> R.drawable.img_zklink_22 + Blockchain.ZkSyncEra, Blockchain.ZkSyncEraTestnet -> R.drawable.img_zksync_22 + Blockchain.Nexa, Blockchain.NexaTestnet, Blockchain.Unknown -> R.drawable.ic_alert_24 + } + TestModel(blockchain, expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetGreyedOutIconRes { + + @ParameterizedTest + @ProvideTestModels + fun returnsCorrectDrawable(model: TestModel) { + assertThat(getGreyedOutIconRes(model.input)).isEqualTo(model.expected) + } + + @Test + fun returnsAlertDrawableForUnknownId() { + assertThat(getGreyedOutIconRes(Blockchain.Unknown)).isEqualTo(R.drawable.ic_alert_24) + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + private fun provideTestModels() = Blockchain.entries.map { blockchain -> + val expected = when (blockchain) { + Blockchain.Alephium, Blockchain.AlephiumTestnet -> R.drawable.ic_alephium_22 + Blockchain.AlephZero, Blockchain.AlephZeroTestnet -> R.drawable.ic_azero_22 + Blockchain.Algorand, Blockchain.AlgorandTestnet -> R.drawable.ic_algorand_22 + Blockchain.ApeChain, Blockchain.ApeChainTestnet -> R.drawable.ic_apecoin_22 + Blockchain.Aptos, Blockchain.AptosTestnet -> R.drawable.ic_aptos_22 + Blockchain.Arbitrum, Blockchain.ArbitrumTestnet -> R.drawable.ic_arbitrum_22 + Blockchain.ArbitrumNova -> R.drawable.ic_arbitrum_nova_22 + Blockchain.Areon, Blockchain.AreonTestnet -> R.drawable.ic_areon_22 + Blockchain.Aurora, Blockchain.AuroraTestnet -> R.drawable.ic_aurora_22 + Blockchain.Avalanche, Blockchain.AvalancheTestnet -> R.drawable.ic_avalanche_22 + Blockchain.BSC, Blockchain.BSCTestnet, + Blockchain.Binance, Blockchain.BinanceTestnet, + -> R.drawable.ic_bsc_16 + Blockchain.Base, Blockchain.BaseTestnet -> R.drawable.ic_base_22 + Blockchain.Bitcoin, Blockchain.BitcoinTestnet -> R.drawable.ic_bitcoin_16 + Blockchain.BitcoinCash, Blockchain.BitcoinCashTestnet -> R.drawable.ic_bitcoin_cash_16 + Blockchain.Bitrock, Blockchain.BitrockTestnet -> R.drawable.ic_bitrock_22 + Blockchain.Bittensor -> R.drawable.ic_bittensor_22 + Blockchain.Blast, Blockchain.BlastTestnet -> R.drawable.ic_blast_22 + Blockchain.Canxium -> R.drawable.ic_canxium_22 + Blockchain.Cardano -> R.drawable.ic_cardano_16 + Blockchain.Casper, Blockchain.CasperTestnet -> R.drawable.ic_casper_22 + Blockchain.Chia, Blockchain.ChiaTestnet -> R.drawable.ic_chia_22 + Blockchain.Chiliz, Blockchain.ChilizTestnet -> R.drawable.ic_chiliz_22 + Blockchain.Clore -> R.drawable.ic_clore_22 + Blockchain.Core, Blockchain.CoreTestnet -> R.drawable.ic_core_22 + Blockchain.Cosmos, Blockchain.CosmosTestnet -> R.drawable.ic_cosmos_22 + Blockchain.Cronos -> R.drawable.ic_cronos_22 + Blockchain.Cyber, Blockchain.CyberTestnet -> R.drawable.ic_cyber_22 + Blockchain.Dash -> R.drawable.ic_dash_22 + Blockchain.Decimal, Blockchain.DecimalTestnet -> R.drawable.ic_decimal_22 + Blockchain.Dischain -> R.drawable.ic_dischain_22 + Blockchain.Dogecoin -> R.drawable.ic_dogecoin_16 + Blockchain.Ducatus -> R.drawable.ic_ducatus_22 + Blockchain.EnergyWebChain, Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, Blockchain.EnergyWebXTestnet, + -> R.drawable.ic_energy_web_22 + Blockchain.Ethereum, Blockchain.EthereumTestnet -> R.drawable.ic_eth_16 + Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet -> R.drawable.ic_eth_16 + Blockchain.EthereumPow, Blockchain.EthereumPowTestnet -> R.drawable.ic_ethereumpow_22 + Blockchain.Fact0rn -> R.drawable.ic_fact0rn_22 + Blockchain.Fantom, Blockchain.FantomTestnet -> R.drawable.ic_fantom_22 + Blockchain.Filecoin -> R.drawable.ic_filecoin_22 + Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.ic_flare_22 + Blockchain.Gnosis -> R.drawable.ic_gnosis_22 + Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.ic_hedera_22 + Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.ic_hyperliquid_22 + Blockchain.InternetComputer -> R.drawable.ic_icp_22 + Blockchain.Joystream -> R.drawable.ic_joystream_22 + Blockchain.Kaspa, Blockchain.KaspaTestnet -> R.drawable.ic_kaspa_22 + Blockchain.Kava, Blockchain.KavaTestnet -> R.drawable.ic_kava_22 + Blockchain.Koinos, Blockchain.KoinosTestnet -> R.drawable.ic_koinos_22 + Blockchain.Kusama -> R.drawable.ic_kusama_16 + Blockchain.Linea, Blockchain.LineaTestnet -> R.drawable.ic_linea_22 + Blockchain.Litecoin -> R.drawable.ic_litecoin_22 + Blockchain.Manta, Blockchain.MantaTestnet -> R.drawable.ic_manta_22 + Blockchain.Mantle, Blockchain.MantleTestnet -> R.drawable.ic_mantle_22 + Blockchain.Monad, Blockchain.MonadTestnet -> R.drawable.ic_monad_22 + Blockchain.Moonbeam, Blockchain.MoonbeamTestnet -> R.drawable.ic_moonbeam_22 + Blockchain.Moonriver, Blockchain.MoonriverTestnet -> R.drawable.ic_moonriver_22 + Blockchain.Near, Blockchain.NearTestnet -> R.drawable.ic_near_22 + Blockchain.OctaSpace, Blockchain.OctaSpaceTestnet -> R.drawable.ic_octaspace_22 + Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet -> R.drawable.ic_odyssey_chain_22 + Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.ic_optimism_22 + Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> R.drawable.ic_pepecoin_22 + Blockchain.Plasma, Blockchain.PlasmaTestnet -> R.drawable.ic_plasma_22 + Blockchain.Playa3ull -> R.drawable.ic_playa3ull_22 + Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.ic_polkadot_16 + Blockchain.Polygon, Blockchain.PolygonTestnet -> R.drawable.ic_polygon_22 + Blockchain.PolygonZkEVM, Blockchain.PolygonZkEVMTestnet -> R.drawable.ic_polygon_22 + Blockchain.PulseChain, Blockchain.PulseChainTestnet -> R.drawable.ic_pls_22 + Blockchain.Quai, Blockchain.QuaiTestnet -> R.drawable.ic_quai_22 + Blockchain.RSK -> R.drawable.ic_rsk_16 + Blockchain.Radiant -> R.drawable.ic_radiant_22 + Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> R.drawable.ic_ravencoin_22 + Blockchain.Scroll, Blockchain.ScrollTestnet -> R.drawable.ic_scroll_22 + Blockchain.Sei, Blockchain.SeiTestnet -> R.drawable.ic_sei_22 + Blockchain.Shibarium, Blockchain.ShibariumTestnet -> R.drawable.ic_shibarium_22 + Blockchain.Solana, Blockchain.SolanaTestnet -> R.drawable.ic_solana_16 + Blockchain.Sonic, Blockchain.SonicTestnet -> R.drawable.ic_sonic_22 + Blockchain.Stellar, Blockchain.StellarTestnet -> R.drawable.ic_stellar_16 + Blockchain.Sui, Blockchain.SuiTestnet -> R.drawable.ic_sui_22 + Blockchain.TON, Blockchain.TONTestnet -> R.drawable.ic_ton_22 + Blockchain.Taraxa, Blockchain.TaraxaTestnet -> R.drawable.ic_taraxa_22 + Blockchain.Telos, Blockchain.TelosTestnet -> R.drawable.ic_telos_22 + Blockchain.TerraV1 -> R.drawable.ic_terra_22 + Blockchain.TerraV2 -> R.drawable.ic_terra2_22 + Blockchain.Tezos -> R.drawable.ic_tezos_16 + Blockchain.Tron, Blockchain.TronTestnet -> R.drawable.ic_tron_22 + Blockchain.VanarChain, Blockchain.VanarChainTestnet -> R.drawable.ic_vanar_22 + Blockchain.VeChain, Blockchain.VeChainTestnet -> R.drawable.ic_vechain_22 + Blockchain.XDC, Blockchain.XDCTestnet -> R.drawable.ic_xdc_22 + Blockchain.XRP -> R.drawable.ic_xrp_22 + Blockchain.Xodex -> R.drawable.ic_xodex_22 + Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> R.drawable.ic_zklink_22 + Blockchain.ZkSyncEra, Blockchain.ZkSyncEraTestnet -> R.drawable.ic_zksync_22 + Blockchain.Nexa, Blockchain.NexaTestnet, Blockchain.Unknown -> R.drawable.ic_alert_24 + } + TestModel(blockchain, expected) + } + } + + data class TestModel(val input: Blockchain, val expected: Int) +} \ No newline at end of file diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index 819db765b9..60348ec1a4 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** DI */ @@ -25,4 +29,8 @@ dependencies { /** For calculating user id hash */ implementation(tangemDeps.card.core) + + /** Tests */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index a670a8226a..d6b77eb6ba 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -99,6 +99,7 @@ sealed class AnalyticsParam { data object NewsPage : ScreensSources("News Page") data object Portfolio : ScreensSources("Portfolio") data object Staking : ScreensSources("Staking") + data object Earn : ScreensSources("Earn") } sealed class TxSentFrom(val value: String) { diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/AssetsDiscoveryAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/AssetsDiscoveryAnalyticsEvent.kt new file mode 100644 index 0000000000..2961459d3d --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/AssetsDiscoveryAnalyticsEvent.kt @@ -0,0 +1,17 @@ +package com.tangem.core.analytics.models.event + +import com.tangem.core.analytics.models.AnalyticsEvent + +sealed class AssetsDiscoveryAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Token Sync", event = event, params = params) { + + class SyncStarted : AssetsDiscoveryAnalyticsEvent(event = "Sync Started") + + class SyncCompleted : AssetsDiscoveryAnalyticsEvent(event = "Sync Completed") + + class ButtonManageTokens : AssetsDiscoveryAnalyticsEvent(event = "Button - Manage Tokens") + + class ButtonCloseBanner : AssetsDiscoveryAnalyticsEvent(event = "Button - Close Banner") +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/di/LastSignedWalletFormStoreModule.kt b/core/analytics/src/main/java/com/tangem/core/analytics/di/LastSignedWalletFormStoreModule.kt new file mode 100644 index 0000000000..cc0f32e6ac --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/di/LastSignedWalletFormStoreModule.kt @@ -0,0 +1,16 @@ +package com.tangem.core.analytics.di + +import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor +import com.tangem.core.analytics.store.LastSignedWalletFormStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface LastSignedWalletFormStoreModule { + + @Binds + fun bindLastSignedWalletFormStore(impl: SendTransactionSignerInfoInterceptor): LastSignedWalletFormStore +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptor.kt b/core/analytics/src/main/java/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptor.kt new file mode 100644 index 0000000000..ee56dca5cf --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptor.kt @@ -0,0 +1,34 @@ +package com.tangem.core.analytics.paramsinterceptor + +import com.tangem.core.analytics.api.ParamsInterceptor +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.analytics.store.LastSignedWalletFormStore +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SendTransactionSignerInfoInterceptor @Inject constructor() : + ParamsInterceptor, + LastSignedWalletFormStore { + + private val walletForm = MutableStateFlow(Basic.TransactionSent.WalletForm.Card) + + override fun update(form: Basic.TransactionSent.WalletForm) { + walletForm.value = form + } + + override fun id(): String = ID + + override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is Basic.TransactionSent + + override fun intercept(params: MutableMap) { + params[AnalyticsParam.WALLET_FORM] = walletForm.value.name + } + + private companion object { + const val ID = "SendTransactionSignerInfoInterceptor" + } +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/store/LastSignedWalletFormStore.kt b/core/analytics/src/main/java/com/tangem/core/analytics/store/LastSignedWalletFormStore.kt new file mode 100644 index 0000000000..b44f753e15 --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/store/LastSignedWalletFormStore.kt @@ -0,0 +1,8 @@ +package com.tangem.core.analytics.store + +import com.tangem.core.analytics.models.Basic + +interface LastSignedWalletFormStore { + + fun update(form: Basic.TransactionSent.WalletForm) +} \ No newline at end of file diff --git a/core/analytics/src/test/kotlin/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptorTest.kt b/core/analytics/src/test/kotlin/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptorTest.kt new file mode 100644 index 0000000000..d1f6f22bf2 --- /dev/null +++ b/core/analytics/src/test/kotlin/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptorTest.kt @@ -0,0 +1,83 @@ +package com.tangem.core.analytics.paramsinterceptor + +import com.google.common.truth.Truth +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SendTransactionSignerInfoInterceptorTest { + + private lateinit var interceptor: SendTransactionSignerInfoInterceptor + + @BeforeEach + fun setUp() { + interceptor = SendTransactionSignerInfoInterceptor() + } + + @Test + fun `id returns stable identifier`() { + Truth.assertThat(interceptor.id()).isEqualTo("SendTransactionSignerInfoInterceptor") + } + + @Test + fun `canBeAppliedTo returns true for TransactionSent event`() { + val event = mockk() + + Truth.assertThat(interceptor.canBeAppliedTo(event)).isTrue() + } + + @Test + fun `canBeAppliedTo returns false for any other event`() { + val event = mockk() + + Truth.assertThat(interceptor.canBeAppliedTo(event)).isFalse() + } + + @Test + fun `intercept writes Card by default`() { + val params = mutableMapOf() + + interceptor.intercept(params) + + Truth.assertThat(params[AnalyticsParam.WALLET_FORM]) + .isEqualTo(Basic.TransactionSent.WalletForm.Card.name) + } + + @Test + fun `intercept writes last updated wallet form`() { + interceptor.update(Basic.TransactionSent.WalletForm.Ring) + val params = mutableMapOf() + + interceptor.intercept(params) + + Truth.assertThat(params[AnalyticsParam.WALLET_FORM]) + .isEqualTo(Basic.TransactionSent.WalletForm.Ring.name) + } + + @Test + fun `update overrides previous wallet form`() { + interceptor.update(Basic.TransactionSent.WalletForm.Ring) + interceptor.update(Basic.TransactionSent.WalletForm.Card) + val params = mutableMapOf() + + interceptor.intercept(params) + + Truth.assertThat(params[AnalyticsParam.WALLET_FORM]) + .isEqualTo(Basic.TransactionSent.WalletForm.Card.name) + } + + @Test + fun `intercept preserves other params`() { + val params = mutableMapOf("Source" to "Send") + + interceptor.intercept(params) + + Truth.assertThat(params).containsEntry("Source", "Send") + Truth.assertThat(params).containsKey(AnalyticsParam.WALLET_FORM) + } +} \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json index 59809c4e40..3d5c1150dc 100644 --- a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json +++ b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json @@ -15,24 +15,8 @@ "name": "vanar-chain", "version": "undefined" }, - { - "name": "sonic", - "version": "5.21.0" - }, - { - "name": "apechain", - "version": "5.21.0" - }, - { - "name": "alephium", - "version": "5.21.0" - }, { "name": "zklink", "version": "undefined" - }, - { - "name": "plasma", - "version": "5.31" } ] \ 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 b6fbd1d807..c161b3d49d 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 @@ -7,7 +7,6 @@ "name": "VISA_ONBOARDING_ENABLED", "version": "undefined" }, - { "name": "STAKING_ETH_ENABLED", "version": "undefined" @@ -20,50 +19,18 @@ "name": "SWAP_REDESIGN_ENABLED", "version": "undefined" }, - { - "name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED", - "version": "5.32.0" - }, { "name": "APP_REDESIGN_ENABLED", "version": "undefined" }, - { - "name": "SWAP_MARKET_LIST_ENABLED", - "version": "5.34" - }, - { - "name": "EARN_BLOCK_ENABLED", - "version": "5.35" - }, - { - "name": "HOLD_TO_CONFIRM_BUTTON_ENABLED", - "version": "5.35" - }, - { - "name": "WALLET_REORDER_FEATURE_ENABLED", - "version": "5.34" - }, { "name": "GASLESS_APPROVAL_ENABLED", "version": "5.37" }, - { - "name": "TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED", - "version": "5.37" - }, { "name": "DYNAMIC_ADDRESSES_ENABLED", "version": "undefined" }, - { - "name": "CUSTOMER_IO_ENABLED", - "version": "5.35" - }, - { - "name": "MAIN_SCREEN_QR_SCANNING_ENABLED", - "version": "5.36" - }, { "name": "NEW_PROMO_BANNERS_ENABLED", "version": "5.37" @@ -73,7 +40,7 @@ "version": "undefined" }, { - "name": "TOKEN_SYNC_ENABLED", + "name": "ASSETS_DISCOVERY_ENABLED", "version": "undefined" }, { @@ -87,5 +54,17 @@ { "name": "HEDERA_ERC20_ENABLED", "version": "5.37" + }, + { + "name": "ADD_AND_MANAGE_TOKENS_ENABLED", + "version": "undefined" + }, + { + "name": "WALLET_CONNECT_BITCOIN_ENABLED", + "version": "undefined" + }, + { + "name": "ADDRESS_SYNC_ENABLED", + "version": "undefined" } ] diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt index 830cc43c53..507dd085c9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt @@ -4,34 +4,6 @@ import android.util.Log import com.ihsanbal.logging.Level import com.ihsanbal.logging.LoggingInterceptor import okhttp3.Interceptor -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import java.util.concurrent.TimeUnit - -@Suppress("MagicNumber") -@Deprecated("Create and provide by DI") -fun createRetrofitInstance( - baseUrl: String, - okHttpBuilder: OkHttpClient.Builder = OkHttpClient.Builder(), - interceptors: List = emptyList(), - logEnabled: Boolean, -): Retrofit { - okHttpBuilder.apply { - callTimeout(10, TimeUnit.SECONDS) - connectTimeout(20, TimeUnit.SECONDS) - readTimeout(20, TimeUnit.SECONDS) - writeTimeout(20, TimeUnit.SECONDS) - } - interceptors.forEach { okHttpBuilder.addInterceptor(it) } - - if (logEnabled) okHttpBuilder.addInterceptor(createNetworkLoggingInterceptor()) - - return Retrofit.Builder() - .baseUrl(baseUrl) - .addConverterFactory(MoshiConverter.networkMoshiConverter) - .client(okHttpBuilder.build()) - .build() -} fun createNetworkLoggingInterceptor(): Interceptor { return LoggingInterceptor.Builder() diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt index 3cf8112a90..b0c61cbfc6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -6,20 +6,17 @@ import com.tangem.datasource.utils.RequestHeader import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** * Express [ApiConfig] * * @property environmentConfig environment config * @property expressAuthProvider express auth provider - * @property appVersionProvider app version provider * @property appInfoProvider app info provider */ internal class Express( private val environmentConfig: EnvironmentConfig, private val expressAuthProvider: ExpressAuthProvider, - private val appVersionProvider: AppVersionProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -102,7 +99,7 @@ internal class Express( private fun createHeaders(isProd: Boolean) = buildMap { put(key = "api-key", value = ProviderSuspend { getApiKey(isProd) }) put(key = "session-id", value = ProviderSuspend(expressAuthProvider::getSessionId)) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) } private fun getApiKey(isProd: Boolean): String { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt index 35935f0c9d..111d593100 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt @@ -6,14 +6,12 @@ import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** * Gasless transactions [ApiConfig] */ internal class GaslessTxService( private val authProvider: AuthProvider, - private val appVersionProvider: AppVersionProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -50,7 +48,7 @@ internal class GaslessTxService( ) private fun createHeaders(environment: ApiEnvironment) = buildMap { - putAll(RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) put( key = "Authorization", value = ProviderSuspend { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt index 26b8b74472..8e4138d515 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt @@ -5,14 +5,12 @@ import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** * News [ApiConfig] [REDACTED_AUTHOR] */ internal class News( - private val appVersionProvider: AppVersionProvider, private val appInfoProvider: AppInfoProvider, private val authProvider: AuthProvider, ) : ApiConfig() { @@ -64,7 +62,7 @@ internal class News( apiEnvironment = Provider { environment }, ).values, ) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) } private companion object { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index f6c8752c2d..a7dd87e4db 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -3,11 +3,11 @@ package com.tangem.datasource.api.common.config import com.tangem.datasource.BuildConfig import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.utils.ProviderSuspend -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider internal sealed class TangemPay( private val environmentConfig: EnvironmentConfig, - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() @@ -52,7 +52,7 @@ internal sealed class TangemPay( ) private fun createHeaders(apiEnvironment: ApiEnvironment) = mapOf( - "version" to ProviderSuspend { appVersionProvider.versionName }, + "version" to ProviderSuspend { appInfoProvider.appVersion }, "platform" to ProviderSuspend { "Android" }, "X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) }, ) @@ -74,8 +74,8 @@ internal sealed class TangemPay( class Bff( environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ) : TangemPay(environmentConfig, appVersionProvider) { + appInfoProvider: AppInfoProvider, + ) : TangemPay(environmentConfig, appInfoProvider) { override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { return when (apiEnvironment) { ApiEnvironment.DEV -> "https://api.dev.us.paera.com/bff-v2/" @@ -93,8 +93,8 @@ internal sealed class TangemPay( class Auth( environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ) : TangemPay(environmentConfig, appVersionProvider) { + appInfoProvider: AppInfoProvider, + ) : TangemPay(environmentConfig, appInfoProvider) { override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { return when (apiEnvironment) { ApiEnvironment.DEV -> "https://api.dev.us.paera.com/" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt index 96ef551d23..9500ec26e3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt @@ -5,11 +5,9 @@ import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** TangemTech [ApiConfig] */ internal class TangemTech( - private val appVersionProvider: AppVersionProvider, private val authProvider: AuthProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -62,7 +60,7 @@ internal class TangemTech( private fun createHeaders(apiEnvironment: ApiEnvironment) = buildMap { putAll(from = RequestHeader.TangemApiKeyHeader(authProvider, Provider { apiEnvironment }).values) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) putAll(from = RequestHeader.AuthenticationHeader(authProvider).values) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt index 4964259c7d..6b8f203c1d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt @@ -6,12 +6,10 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** YieldSupply [ApiConfig] */ internal class YieldSupply( private val environmentConfig: EnvironmentConfig, - private val appVersionProvider: AppVersionProvider, private val authProvider: AuthProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -66,7 +64,7 @@ internal class YieldSupply( put(key = "api-key", value = ProviderSuspend { getApiKey(apiEnvironment) }) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) putAll(from = RequestHeader.AuthenticationHeader(authProvider).values) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 8f67d01dae..56359bc593 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -85,6 +85,18 @@ interface TangemPayApi { @Body body: FreezeUnfreezeCardRequest, ): ApiResponse + @GET("v1/fees/{type}") + suspend fun getFee( + @Header("Authorization") authHeader: String, + @Path("type") type: String, + ): ApiResponse + + @POST("v1/customer/card/reissue") + suspend fun reissueCard( + @Header("Authorization") authHeader: String, + @Body body: ReissueCardRequest, + ): ApiResponse + @POST("v1/customer/card/withdraw/data") suspend fun getWithdrawData( @Header("Authorization") authHeader: String, @@ -96,4 +108,11 @@ interface TangemPayApi { @Header("Authorization") authHeader: String, @Body body: WithdrawRequest, ): ApiResponse + + @PATCH("v1/customer/card/{card_id}") + suspend fun updateCard( + @Header("Authorization") authHeader: String, + @Body body: UpdateCardRequest, + @Path("card_id") cardId: String, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ReissueCardRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ReissueCardRequest.kt new file mode 100644 index 0000000000..26b2f0dd53 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ReissueCardRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ReissueCardRequest( + @Json(name = "card_id") val cardId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardRequest.kt new file mode 100644 index 0000000000..3dc16b32d0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardRequest.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UpdateCardRequest( + @Json(name = "display_name") val displayName: String? = null, + @Json(name = "card_limit") val cardLimit: CardLimit? = null, +) { + @JsonClass(generateAdapter = true) + data class CardLimit( + @Json(name = "amount") val amount: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 38e8982664..61fdefe5b2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.math.BigDecimal @JsonClass(generateAdapter = true) data class CustomerMeResponse( @@ -29,6 +30,9 @@ data class CustomerMeResponse( @Json(name = "status") val status: Status, @Json(name = "updated_at") val updatedAt: String, @Json(name = "payment_account_id") val paymentAccountId: String, + @Json(name = "display_name") val displayName: String?, + @Json(name = "actual_card_limit") val actualCardLimit: CardLimit?, + @Json(name = "admin_card_limit") val adminCardLimit: CardLimit?, ) { @JsonClass(generateAdapter = false) enum class Status { @@ -70,6 +74,12 @@ data class CustomerMeResponse( } } + @JsonClass(generateAdapter = true) + data class CardLimit( + @Json(name = "amount") val amount: BigDecimal, + @Json(name = "period_type") val periodType: String, + ) + @JsonClass(generateAdapter = true) data class PaymentAccount( @Json(name = "id") val id: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FeeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FeeResponse.kt new file mode 100644 index 0000000000..f18a34f075 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FeeResponse.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class FeeResponse( + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "type") val type: String, + @Json(name = "amount") val amount: String, + @Json(name = "currency") val currency: String, + @Json(name = "description") val description: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt new file mode 100644 index 0000000000..4b173d08f3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ReissueCardResponse( + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "order_id") val orderId: String, + @Json(name = "status") val status: OrderResponse.Result.Status, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/UpdateCardDisplayNameResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/UpdateCardDisplayNameResponse.kt new file mode 100644 index 0000000000..feb64ea566 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/UpdateCardDisplayNameResponse.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UpdateCardDisplayNameResponse( + @Json(name = "result") val result: Result?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "display_name") val displayName: String?, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index 4fe788204a..a4dccdfd90 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -42,7 +42,8 @@ data class UserTokensResponse( return otherToken.contractAddress == this.contractAddress && otherToken.networkId == this.networkId && otherToken.derivationPath == this.derivationPath && - otherToken.decimals == this.decimals + otherToken.decimals == this.decimals && + otherToken.dynamicAddressesEnabled == this.dynamicAddressesEnabled } override fun hashCode(): Int = calculateHashCode( @@ -50,6 +51,7 @@ data class UserTokensResponse( networkId.hashCode(), derivationPath.hashCode(), decimals.hashCode(), + dynamicAddressesEnabled.hashCode(), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/crypto/MockDataSignatureVerifier.kt b/core/datasource/src/main/java/com/tangem/datasource/crypto/MockDataSignatureVerifier.kt new file mode 100644 index 0000000000..79f0b6cb63 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/crypto/MockDataSignatureVerifier.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.crypto + +internal class MockDataSignatureVerifier : DataSignatureVerifier { + + override fun verifySignature(signature: String, data: String): Boolean = true +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index e8db8ec219..e341d9724b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -7,7 +7,6 @@ import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,13 +22,11 @@ internal object ApiConfigsModule { fun provideExpressConfig( environmentConfig: EnvironmentConfig, expressAuthProvider: ExpressAuthProvider, - appVersionProvider: AppVersionProvider, appInfoProvider: AppInfoProvider, ): ApiConfig { return Express( environmentConfig = environmentConfig, expressAuthProvider = expressAuthProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } @@ -48,55 +45,47 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideTangemTechConfig( - appVersionProvider: AppVersionProvider, - authProvider: AuthProvider, - appInfoProvider: AppInfoProvider, - ): ApiConfig = TangemTech( - appVersionProvider = appVersionProvider, - authProvider = authProvider, - appInfoProvider = appInfoProvider, - ) + fun provideTangemTechConfig(authProvider: AuthProvider, appInfoProvider: AppInfoProvider): ApiConfig { + return TangemTech( + authProvider = authProvider, + appInfoProvider = appInfoProvider, + ) + } @Provides @IntoSet - fun provideNewsConfig( - appVersionProvider: AppVersionProvider, - authProvider: AuthProvider, - appInfoProvider: AppInfoProvider, - ): ApiConfig = News( - appVersionProvider = appVersionProvider, - appInfoProvider = appInfoProvider, - authProvider = authProvider, - ) + fun provideNewsConfig(authProvider: AuthProvider, appInfoProvider: AppInfoProvider): ApiConfig { + return News( + appInfoProvider = appInfoProvider, + authProvider = authProvider, + ) + } @Provides @IntoSet fun provideYieldSupplyConfig( environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, authProvider: AuthProvider, appInfoProvider: AppInfoProvider, - ): ApiConfig = YieldSupply( - environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, - authProvider = authProvider, - appInfoProvider = appInfoProvider, - ) + ): ApiConfig { + return YieldSupply( + environmentConfig = environmentConfig, + authProvider = authProvider, + appInfoProvider = appInfoProvider, + ) + } @Provides @IntoSet - fun provideTangemPayBffConfig( - environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ): ApiConfig = TangemPay.Bff(environmentConfig, appVersionProvider) + fun provideTangemPayBffConfig(environmentConfig: EnvironmentConfig, appInfoProvider: AppInfoProvider): ApiConfig { + return TangemPay.Bff(environmentConfig, appInfoProvider) + } @Provides @IntoSet - fun provideTangemPayAuthConfig( - environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ): ApiConfig = TangemPay.Auth(environmentConfig, appVersionProvider) + fun provideTangemPayAuthConfig(environmentConfig: EnvironmentConfig, appInfoProvider: AppInfoProvider): ApiConfig { + return TangemPay.Auth(environmentConfig, appInfoProvider) + } @Provides @IntoSet @@ -112,14 +101,9 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideGaslessServiceConfig( - appVersionProvider: AppVersionProvider, - authProvider: AuthProvider, - appInfoProvider: AppInfoProvider, - ): ApiConfig { + fun provideGaslessServiceConfig(authProvider: AuthProvider, appInfoProvider: AppInfoProvider): ApiConfig { return GaslessTxService( authProvider = authProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt deleted file mode 100644 index c67a5baa1f..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.datasource.di - -import com.tangem.datasource.info.AndroidAppInfoProvider -import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object AppInfoModule { - - @Singleton - @Provides - fun provideAppInfoProvider(appVersionProvider: AppVersionProvider): AppInfoProvider { - return AndroidAppInfoProvider(appVersionProvider) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 3dd04dc106..61e2c944ec 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -52,7 +52,7 @@ class MoshiModule { .withSubtype(PaymentAccountStatusValueDM.NotCreated::class.java, "not_created") .withSubtype(PaymentAccountStatusValueDM.UnderReview::class.java, "kyc_status") .withSubtype(PaymentAccountStatusValueDM.IssuingCard::class.java, "issuing_card") - .withSubtype(PaymentAccountStatusValueDM.ActiveCard::class.java, "active_card") + .withSubtype(PaymentAccountStatusValueDM.ActiveAccount::class.java, "active_account") .withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account") .withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 6689718905..b16deb94e1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -36,11 +36,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) internal object NetworkModule { - private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L - private const val TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS = 60L - private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L - - private const val P2P_ETH_POOL_API_TIMEOUT_SECONDS = 60L + private const val TANGEM_LONG_TIMEOUT_SECONDS = 60L @Provides @Singleton @@ -72,10 +68,10 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.StakeKit, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, - connectTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, - readTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, - writeTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), ) } @@ -87,10 +83,10 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.P2PEthPool, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, - connectTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, - readTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, - writeTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), ) } @@ -129,9 +125,9 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.TangemTech, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, - connectTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, - readTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), logsSaving = false, ) @@ -143,6 +139,11 @@ internal object NetworkModule { return retrofitApiBuilder.build( apiConfigId = ApiConfig.ID.TangemPay, applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + ), ) } @@ -152,6 +153,11 @@ internal object NetworkModule { return retrofitApiBuilder.build( apiConfigId = ApiConfig.ID.TangemPay, applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + ), ) } @@ -198,10 +204,10 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.GaslessTxService, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, - connectTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, - readTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, - writeTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt index c04614c1f7..49ba336022 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt @@ -1,7 +1,9 @@ package com.tangem.datasource.di +import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.crypto.MockDataSignatureVerifier import com.tangem.datasource.crypto.Sha256SignatureVerifier import com.tangem.datasource.local.config.environment.EnvironmentConfig import dagger.Module @@ -20,6 +22,10 @@ internal object SecurityModule { environmentConfig: EnvironmentConfig, apiConfigsManager: ApiConfigsManager, ): DataSignatureVerifier { - return Sha256SignatureVerifier(environmentConfig, apiConfigsManager) + return if (BuildConfig.MOCK_DATA_SOURCE) { + MockDataSignatureVerifier() + } else { + Sha256SignatureVerifier(environmentConfig, apiConfigsManager) + } } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt index ae3097d006..135c8bfaa8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt @@ -2,7 +2,9 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeDataStore import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.TangemPayReissueCardStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,4 +22,12 @@ internal object TangemPayStoresModule { dataStore = RuntimeDataStore(), ) } + + @Provides + @Singleton + fun provideTangemPayReissueCardStore(): TangemPayReissueCardStore { + return DefaultTangemPayReissueCardStore( + feeStore = RuntimeDataStore(), + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt index 857855c019..da2ed27af0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt @@ -11,7 +11,7 @@ object ExpressUtils { private const val BATCH_ID_CHANGENOW = "BB000013" private const val BATCH_ID_PARTNER = "AF990015" - fun getRefCode(userWallet: UserWallet, appPreferencesStore: AppPreferencesStore): String? { + fun getRefCode(userWallet: UserWallet?, appPreferencesStore: AppPreferencesStore): String? { return when (userWallet) { is UserWallet.Cold -> { when { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index 21e234d6f5..c148966eba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -10,6 +10,7 @@ data class EnvironmentConfig( val mercuryoWidgetId: String = "", val mercuryoSecret: String = "", val amplitudeApiKey: String = "", + val amplitudeApiKeyDev: String? = null, val appsFlyerApiKey: String = "", val appsAppId: String = "", val blockchainSdkConfig: BlockchainSdkConfig = BlockchainSdkConfig(), diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt index 28644f5c6b..f1f941c2b4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -28,6 +28,7 @@ internal object GeneratedEnvironmentConfigConverter { mercuryoSecret = GeneratedEnvironmentConfig.mercuryoSecret, blockchainSdkConfig = createBlockchainSdkConfig(), amplitudeApiKey = GeneratedEnvironmentConfig.amplitudeApiKey, + amplitudeApiKeyDev = GeneratedEnvironmentConfig.amplitudeApiKeyDev, appsFlyerApiKey = AppsFlyer.appsFlyerDevKey, appsAppId = AppsFlyer.appsFlyerAppID, walletConnectProjectId = GeneratedEnvironmentConfig.walletConnectProjectId, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt index 65dd45cf46..1704e959a9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt @@ -70,12 +70,17 @@ class AppLogsStore @Inject constructor( } } - /** Save log [message] */ - fun saveLogMessage(tag: String, message: String) { + /** + * Save log [message]. Pass [shouldSanitize] = false to bypass [LogsSanitizer]. + * The optional [throwable]'s stack trace is appended verbatim (never sanitized), + * since stack traces routinely contain hex-like sequences that the sanitizer would + * otherwise destroy. + */ + fun saveLogMessage(tag: String, message: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) { launchWithLock { createFileIfNotExist() - writeMessage(tag = tag, message) + writeMessage(tag = tag, shouldSanitize = shouldSanitize, throwable = throwable, message) } } @@ -84,7 +89,7 @@ class AppLogsStore @Inject constructor( launchWithLock { createFileIfNotExist() - writeMessage(tag = tag, *messages) + writeMessage(tag = tag, shouldSanitize = true, throwable = null, messages = messages) } } @@ -97,24 +102,16 @@ class AppLogsStore @Inject constructor( } } - fun deleteOldLogsFile() { - val file = File(applicationContext.filesDir, LOG_FILE_NAME) - - if (file.exists()) file.delete() - } - - fun deleteLastLogFile() { - val file = File(applicationContext.filesDir, NEW_LOG_FILE_NAME) - - if (file.exists()) file.delete() - } - - private fun writeMessage(tag: String, vararg messages: String) { + private fun writeMessage(tag: String, shouldSanitize: Boolean, throwable: Throwable?, vararg messages: String) { BufferedWriter(FileWriter(logFile, true)).use { writer -> writer.append(formatter.print(DateTime.now())) writer.append(": $tag ") - messages.map(LogsSanitizer::sanitize) - .forEach(writer::append) + val processed = if (shouldSanitize) messages.map(LogsSanitizer::sanitize) else messages.toList() + processed.forEach(writer::append) + if (throwable != null) { + writer.newLine() + writer.append(throwable.stackTraceToString().trimEnd()) + } writer.newLine() } } @@ -166,8 +163,6 @@ class AppLogsStore @Inject constructor( private companion object { const val BUFFER_SIZE = 1024 - const val LOG_FILE_NAME = "logs.txt" - const val NEW_LOG_FILE_NAME = "app_logs.txt" // the only name that we allow to send as email to company addresses const val PERMITTED_FILE_NAME = "log.txt" const val PERMITTED_FILE_NAME_ZIP = "log.zip" diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt index 8d7cd539c0..f05d2b8240 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt @@ -29,11 +29,12 @@ sealed interface NetworkStatusDM { /** * Verified * - * @property networkId network id - * @property derivationPath derivation path - * @property selectedAddress selected address - * @property availableAddresses available addresses - * @property amounts amounts + * @property networkId network id + * @property derivationPath derivation path + * @property selectedAddress selected address + * @property availableAddresses available addresses + * @property amounts amounts + * @property yieldSupplyStatuses yield supply statuses */ @NameLabel("amounts") data class Verified( @@ -65,6 +66,11 @@ sealed interface NetworkStatusDM { @Json(name = "error_message") val errorMessage: String, ) : NetworkStatusDM + /** + * Id + * + * @property value blockchain id [com.tangem.blockchain.common.Blockchain.id] + */ @JsonClass(generateAdapter = true) data class ID( @Json(name = "value") val value: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt index db9df71d9d..49fc4000a4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt @@ -16,6 +16,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PR import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_RING_PROMO_KEY import com.tangem.datasource.local.preferences.utils.CleanupKeyMigration import com.tangem.datasource.local.preferences.utils.SharedPreferencesKeyMigration +import com.tangem.datasource.local.preferences.utils.SwapCurrencyIdMigration import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger @@ -80,6 +81,7 @@ internal object PreferencesDataStore { legacyKeyName = LEGACY_DEFAULT_KEY_NAME, keyName = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY.name, ), + SwapCurrencyIdMigration(), CleanupKeyMigration(key = APP_LOGS_KEY), CleanupKeyMigration(key = IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY), CleanupKeyMigration(key = IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY), diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index be52c28b41..f03cdfb336 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -136,7 +136,7 @@ object PreferencesKeys { val HAS_HAD_FIRST_TOP_UP_KEY by lazy { stringPreferencesKey(name = "hasHadFirstTopUp") } - val PENDING_DISCOVERY_SYNC_KEY by lazy { stringPreferencesKey(name = "pendingDiscoverySync") } + val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") } // region Notifications val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigration.kt new file mode 100644 index 0000000000..aa573c9fae --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigration.kt @@ -0,0 +1,151 @@ +package com.tangem.datasource.local.preferences.utils + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.Preferences +import com.tangem.datasource.local.preferences.PreferencesKeys + +/** + * Migrates cached CryptoCurrency.ID strings from old blockchain.id format to new networkId format. + * + * After refactoring, Network.rawId stores backendId values (e.g. "ethereum") instead of + * blockchain.id values (e.g. "ETH"). CryptoCurrency.ID body contains this value, so cached IDs + * like "coin⟨ETH⟩ethereum" must become "coin⟨ethereum⟩ethereum". + * + * Affected DataStore keys: [PreferencesKeys.SWAP_TRANSACTIONS_KEY], + * [PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY]. + */ +internal class SwapCurrencyIdMigration : DataMigration { + + override suspend fun shouldMigrate(currentData: Preferences): Boolean { + return currentData.contains(PreferencesKeys.SWAP_TRANSACTIONS_KEY) || + currentData.contains(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY) + } + + override suspend fun migrate(currentData: Preferences): Preferences { + val mutablePrefs = currentData.toMutablePreferences() + + currentData[PreferencesKeys.SWAP_TRANSACTIONS_KEY]?.let { json -> + mutablePrefs[PreferencesKeys.SWAP_TRANSACTIONS_KEY] = migrateJson(json) + } + + currentData[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY]?.let { json -> + mutablePrefs[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY] = migrateJson(json) + } + + return mutablePrefs.toPreferences() + } + + override suspend fun cleanUp() { + // nothing to clean up + } + + /** + * Replaces old blockchain.id values with networkId values inside CryptoCurrency.ID strings + * found anywhere in the JSON. Works by finding all `⟨oldId⟩` and `⟨oldId→` patterns and + * replacing the old ID with the new one. + */ + private fun migrateJson(json: String): String { + var result = json + + for ((oldId, newId) in BLOCKCHAIN_ID_TO_NETWORK_ID) { + // Body without derivation path: ⟨oldId⟩ → ⟨newId⟩ + result = result.replace("$BODY_START$oldId$BODY_END", "$BODY_START$newId$BODY_END") + // Body with derivation path: ⟨oldId→ → ⟨newId→ + result = result.replace( + "$BODY_START$oldId$DERIVATION_DELIMITER", + "$BODY_START$newId$DERIVATION_DELIMITER", + ) + } + + return result + } + + private companion object { + const val BODY_START = '\u27E8' // ⟨ + const val BODY_END = '\u27E9' // ⟩ + const val DERIVATION_DELIMITER = '\u2192' // → + + /** Mapping of old blockchain.id → new networkId (only entries where values differ). */ + val BLOCKCHAIN_ID_TO_NETWORK_ID = mapOf( + "ARBITRUM-ONE" to "arbitrum-one", + "ARBITRUM/test" to "arbitrum-one/test", + "AVALANCHE" to "avalanche", + "AVALANCHE/test" to "avalanche/test", + "BINANCE" to "binancecoin", + "BINANCE/test" to "binancecoin/test", + "BSC" to "binance-smart-chain", + "BSC/test" to "binance-smart-chain/test", + "BTC" to "bitcoin", + "BTC/test" to "bitcoin/test", + "BCH" to "bitcoin-cash", + "BCH/test" to "bitcoin-cash/test", + "CARDANO-S" to "cardano", + "DOGE" to "dogecoin", + "DUC" to "ducatus", + "ETH" to "ethereum", + "ETH/test" to "ethereum/test", + "ETC" to "ethereum-classic", + "ETC/test" to "ethereum-classic/test", + "ETH-Pow" to "ethereum-pow-iou", + "ETH-Pow/test" to "ethereum-pow-iou/test", + "FTM" to "fantom", + "FTM/test" to "fantom/test", + "GNO" to "xdai", + "KAS" to "kaspa", + "KAS/test" to "kaspa/test", + "KAVA" to "kava", + "KAVA/test" to "kava/test", + "Kusama" to "kusama", + "LTC" to "litecoin", + "NEAR" to "near-protocol", + "NEAR/test" to "near-protocol/test", + "NEXA" to "nexa", + "NEXA/test" to "nexa/test", + "OPTIMISM" to "optimistic-ethereum", + "Polkadot" to "polkadot", + "POLYGON" to "polygon-pos", + "POLYGON/test" to "polygon-pos/test", + "RSK" to "rootstock", + "SOLANA" to "solana", + "SOLANA/test" to "solana/test", + "TELOS" to "telos", + "TELOS/test" to "telos/test", + "The-Open-Network" to "the-open-network", + "The-Open-Network/test" to "the-open-network/test", + "TRON" to "tron", + "TRON/test" to "tron/test", + "XLM" to "stellar", + "XLM/test" to "stellar/test", + "XRP" to "xrp", + "XTZ" to "tezos", + "DASH" to "dash", + "xdc" to "xdc-network", + "xdc/test" to "xdc-network/test", + "hedera" to "hedera-hashgraph", + "hedera/test" to "hedera-hashgraph/test", + "areon" to "areon-network", + "areon/test" to "areon-network/test", + "pls" to "pulsechain", + "pls/test" to "pulsechain/test", + "zkSyncEra" to "zksync", + "zkSyncEra/test" to "zksync/test", + "polygonZkEVM" to "polygon-zkevm", + "polygonZkEVM/test" to "polygon-zkevm/test", + "flare" to "flare-network", + "flare/test" to "flare-network/test", + "playa3ull" to "playa3ull-games", + "sei" to "sei-network", + "sei/test" to "sei-network/test", + "casper" to "casper-network", + "casper/test" to "casper-network/test", + "odyssey" to "dione", + "odyssey/test" to "dione/test", + "hyperliquid" to "hyperevm", + "hyperliquid/test" to "hyperevm/test", + "quai" to "quai-network", + "quai/test" to "quai-network/test", + "manta/test" to "manta-pacific/test", + "dischain" to "ethereumfair", + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt new file mode 100644 index 0000000000..666b4e790c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt @@ -0,0 +1,30 @@ +package com.tangem.datasource.local.visa + +import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.domain.models.pay.TangemPayReissueCardFee +import com.tangem.domain.models.wallet.UserWalletId + +internal class DefaultTangemPayReissueCardStore( + private val feeStore: RuntimeDataStore, +) : TangemPayReissueCardStore { + + override suspend fun storeReissueFee( + userWalletId: UserWalletId, + tangemPayReissueCardFee: TangemPayReissueCardFee, + ) { + feeStore.store(userWalletId.stringValue, tangemPayReissueCardFee) + } + + override suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee? { + return feeStore.getSyncOrNull(userWalletId.stringValue) + } + + override suspend fun storeReissueOrderId(cardId: String, orderId: String) { + // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs + } + + override suspend fun getOrderId(cardId: String): String? { + // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs + return null + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt new file mode 100644 index 0000000000..0925c1ea31 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.local.visa + +import com.tangem.domain.models.pay.TangemPayReissueCardFee +import com.tangem.domain.models.wallet.UserWalletId + +interface TangemPayReissueCardStore { + + suspend fun storeReissueFee(userWalletId: UserWalletId, tangemPayReissueCardFee: TangemPayReissueCardFee) + + suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee? + + suspend fun storeReissueOrderId(cardId: String, orderId: String) + + suspend fun getOrderId(cardId: String): String? +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 37b8f3cee0..d51eed7b2f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -4,6 +4,7 @@ package com.tangem.datasource.local.visa.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.serialization.SerializedBigDecimal import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel import java.math.BigDecimal @@ -37,17 +38,14 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "issuing_card") val marker: Boolean = true, ) : PaymentAccountStatusValueDM - @NameLabel("active_card") - data class ActiveCard( - @Json(name = "active_card") val isLocked: Boolean, + @NameLabel("active_account") + data class ActiveAccount( @Json(name = "customer_id") val customerId: String, - @Json(name = "card_id") val cardId: String, - @Json(name = "last_four_digits") val lastFourDigits: String, @Json(name = "currency_code") val currencyCode: String, @Json(name = "deposit_address") val depositAddress: String?, - @Json(name = "is_pin_set") val isPinSet: Boolean, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, + @Json(name = "cards") val cards: List, ) : PaymentAccountStatusValueDM @NameLabel("card_issue_failed") @@ -76,4 +74,15 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "token_contract_address") val tokenContractAddress: String, @Json(name = "balance") val balance: BigDecimal, ) + + @JsonClass(generateAdapter = true) + data class TangemPayCard( + @Json(name = "id") val id: String, + @Json(name = "has_pin_code") val hasPinCode: Boolean, + @Json(name = "display_name") val displayName: String?, + @Json(name = "actual_daily_limit") val actualDailyLimit: SerializedBigDecimal?, + @Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?, + @Json(name = "is_frozen") val isFrozen: Boolean, + @Json(name = "last_digits") val lastDigits: String, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt index c76773424f..7dae75ccfe 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt @@ -1,5 +1,6 @@ package com.tangem.datasource.utils +import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi @@ -34,7 +35,11 @@ class MoshiDataStoreSerializer( override suspend fun readFrom(input: InputStream): T { return input.bufferedReader().use { reader -> - adapter.fromJson(reader.readText()) ?: defaultValue + try { + adapter.fromJson(reader.readText()) ?: defaultValue + } catch (e: Exception) { + throw CorruptionException("Failed to deserialize data", e) + } } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt index ef2b83b6df..821a90cbba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt @@ -1,14 +1,11 @@ package com.tangem.datasource.utils -import android.os.Build import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.utils.RequestHeader.CacheControlHeader.checkHeaderValueOrEmpty import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider -import java.util.Locale import java.util.TimeZone /** @@ -29,17 +26,16 @@ sealed class RequestHeader(vararg pairs: Pair>) ) class AppVersionPlatformHeaders( - appVersionProvider: AppVersionProvider, appInfoProvider: AppInfoProvider, ) : RequestHeader( "system_version" to ProviderSuspend { appInfoProvider.osVersion }, - "version" to ProviderSuspend { appVersionProvider.versionName }, + "version" to ProviderSuspend { appInfoProvider.appVersion }, "platform" to ProviderSuspend { "android" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { appInfoProvider.language.checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, - "device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() }, + "device" to ProviderSuspend { appInfoProvider.device.checkHeaderValueOrEmpty() }, ) /** diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 1b1693d5a7..2e820ceaac 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -51,21 +51,18 @@ class ApiConfigTest { Express( environmentConfig = environmentConfig, expressAuthProvider = mockk(), - appVersionProvider = mockk(), appInfoProvider = mockk(), ) } ApiConfig.ID.YieldSupply -> { YieldSupply( environmentConfig = environmentConfig, - appVersionProvider = mockk(), authProvider = appAuthProvider, appInfoProvider = mockk(), ) } ApiConfig.ID.TangemTech -> { TangemTech( - appVersionProvider = mockk(), authProvider = appAuthProvider, appInfoProvider = mockk(), ) @@ -73,23 +70,21 @@ class ApiConfigTest { ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk()) ApiConfig.ID.TangemPay -> TangemPay.Bff( environmentConfig = environmentConfig, - appVersionProvider = mockk(), + appInfoProvider = mockk(), ) ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( environmentConfig = environmentConfig, - appVersionProvider = mockk(), + appInfoProvider = mockk(), ) ApiConfig.ID.BlockAid -> BlockAid(environmentConfig = environmentConfig) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) ApiConfig.ID.News -> News( - appVersionProvider = mockk(), authProvider = appAuthProvider, appInfoProvider = mockk(), ) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, - appVersionProvider = mockk(), appInfoProvider = mockk(), ) } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index becd832cbc..102cfd0da8 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -19,7 +19,6 @@ import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.test.core.ProvideTestModels import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every @@ -38,7 +37,6 @@ import java.util.TimeZone internal class ProdApiConfigsManagerTest { private val environmentConfig = createMockEnvironmentConfig() - private val appVersionProvider = mockk() private val expressAuthProvider = mockk() private val stakeKitAuthProvider = mockk() private val p2pEthPoolAuthProvider = mockk() @@ -52,14 +50,13 @@ internal class ProdApiConfigsManagerTest { @BeforeEach fun setup() { clearMocks( - appVersionProvider, expressAuthProvider, stakeKitAuthProvider, appAuthProvider, appInfoProvider, ) - every { appVersionProvider.versionName } returns VERSION_NAME + every { appInfoProvider.appVersion } returns VERSION_NAME every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY @@ -71,6 +68,8 @@ internal class ProdApiConfigsManagerTest { coEvery { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY every { appInfoProvider.osVersion } returns "Android 16" + every { appInfoProvider.language } returns Locale.getDefault().toLanguageTag() + every { appInfoProvider.device } returns "${Build.MANUFACTURER} ${Build.MODEL}" manager = ProdApiConfigsManager(apiConfigs = createApiConfigs()) } @@ -94,21 +93,18 @@ internal class ProdApiConfigsManagerTest { Express( environmentConfig = environmentConfig, expressAuthProvider = expressAuthProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } ApiConfig.ID.YieldSupply -> { YieldSupply( environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) } ApiConfig.ID.TangemTech -> { TangemTech( - appVersionProvider = appVersionProvider, authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) @@ -116,23 +112,21 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider) ApiConfig.ID.TangemPay -> TangemPay.Bff( environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, ) ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, ) ApiConfig.ID.BlockAid -> BlockAid(environmentConfig = environmentConfig) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) ApiConfig.ID.News -> News( - appVersionProvider = appVersionProvider, authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } @@ -195,7 +189,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "system_version" to ProviderSuspend { "Android 16" }, "platform" to ProviderSuspend { "android" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -218,7 +212,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -241,7 +235,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -316,7 +310,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -394,7 +388,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigrationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigrationTest.kt new file mode 100644 index 0000000000..d6e94efd26 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigrationTest.kt @@ -0,0 +1,224 @@ +package com.tangem.datasource.local.preferences.utils + +import androidx.datastore.preferences.core.mutablePreferencesOf +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.preferences.PreferencesKeys +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class SwapCurrencyIdMigrationTest { + + private val migration = SwapCurrencyIdMigration() + + // region shouldMigrate + + @Test + fun `shouldMigrate returns true when swap transactions key exists`() = runTest { + val prefs = mutablePreferencesOf(PreferencesKeys.SWAP_TRANSACTIONS_KEY to "[]") + + assertThat(migration.shouldMigrate(prefs)).isTrue() + } + + @Test + fun `shouldMigrate returns true when last swapped currency key exists`() = runTest { + val prefs = mutablePreferencesOf(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to "[]") + + assertThat(migration.shouldMigrate(prefs)).isTrue() + } + + @Test + fun `shouldMigrate returns true when both keys exist`() = runTest { + val prefs = mutablePreferencesOf( + PreferencesKeys.SWAP_TRANSACTIONS_KEY to "[]", + PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to "[]", + ) + + assertThat(migration.shouldMigrate(prefs)).isTrue() + } + + @Test + fun `shouldMigrate returns false when no keys exist`() = runTest { + val prefs = mutablePreferencesOf() + + assertThat(migration.shouldMigrate(prefs)).isFalse() + } + + // endregion + + // region migrate — coin IDs without derivation path + + @Test + fun `migrates simple coin ID - ETH to ethereum`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}ETH${BE}ethereum")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}ethereum${BE}ethereum")) + } + + @Test + fun `migrates simple coin ID - BTC to bitcoin`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}BTC${BE}bitcoin")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}bitcoin${BE}bitcoin")) + } + + @Test + fun `migrates BSC to binance-smart-chain`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}BSC${BE}binancecoin")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}binance-smart-chain${BE}binancecoin")) + } + + // endregion + + // region migrate — coin IDs with derivation path + + @Test + fun `migrates coin ID with derivation path`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}ETH${DP}12367123${BE}ethereum")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}ethereum${DP}12367123${BE}ethereum")) + } + + @Test + fun `migrates POLYGON with derivation path`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}POLYGON${DP}99999${BE}polygon-pos")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}polygon-pos${DP}99999${BE}polygon-pos")) + } + + // endregion + + // region migrate — token IDs + + @Test + fun `migrates token ID with contract address`() = runTest { + val result = migrateLastSwapped(currencyIdJson("token${BS}ETH${BE}usdt${CA}0xdAC17")) + + assertThat(result).isEqualTo(currencyIdJson("token${BS}ethereum${BE}usdt${CA}0xdAC17")) + } + + @Test + fun `migrates token ID with derivation path and contract address`() = runTest { + val result = migrateLastSwapped( + currencyIdJson("token${BS}ETH${DP}12345${BE}usdt${CA}0xdAC17"), + ) + + assertThat(result).isEqualTo( + currencyIdJson("token${BS}ethereum${DP}12345${BE}usdt${CA}0xdAC17"), + ) + } + + // endregion + + // region migrate — swap transactions (both from and to IDs) + + @Test + fun `migrates both fromCryptoCurrencyId and toCryptoCurrencyId`() = runTest { + val from = "coin${BS}ETH${BE}ethereum" + val to = "coin${BS}BTC${BE}bitcoin" + val oldJson = "[{\"fromCryptoCurrencyId\":\"$from\",\"toCryptoCurrencyId\":\"$to\"}]" + + val expectedFrom = "coin${BS}ethereum${BE}ethereum" + val expectedTo = "coin${BS}bitcoin${BE}bitcoin" + val expected = "[{\"fromCryptoCurrencyId\":\"$expectedFrom\",\"toCryptoCurrencyId\":\"$expectedTo\"}]" + + val result = migrateSwapTransactions(oldJson) + + assertThat(result).isEqualTo(expected) + } + + // endregion + + // region migrate — no-op cases + + @Test + fun `does not modify already migrated IDs`() = runTest { + val json = currencyIdJson("coin${BS}ethereum${BE}ethereum") + + val result = migrateLastSwapped(json) + + assertThat(result).isEqualTo(json) + } + + @Test + fun `does not modify IDs where blockchain id equals networkId`() = runTest { + val json = currencyIdJson("coin${BS}cosmos${BE}cosmos") + + val result = migrateLastSwapped(json) + + assertThat(result).isEqualTo(json) + } + + @Test + fun `does not modify empty list`() = runTest { + val result = migrateLastSwapped("[]") + + assertThat(result).isEqualTo("[]") + } + + // endregion + + // region migrate — multiple entries + + @Test + fun `migrates multiple entries in list`() = runTest { + val old1 = currencyIdValue("coin${BS}ETH${BE}ethereum") + val old2 = currencyIdValue("coin${BS}TRON${BE}tron") + val oldJson = "[$old1,$old2]" + + val new1 = currencyIdValue("coin${BS}ethereum${BE}ethereum") + val new2 = currencyIdValue("coin${BS}tron${BE}tron") + val expected = "[$new1,$new2]" + + val result = migrateLastSwapped(oldJson) + + assertThat(result).isEqualTo(expected) + } + + // endregion + + // region migrate — preserves unrelated keys + + @Test + fun `preserves unrelated preference keys`() = runTest { + val unrelatedKey = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY + val unrelatedValue = "some_value" + val prefs = mutablePreferencesOf( + PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to currencyIdJson("coin${BS}ETH${BE}ethereum"), + unrelatedKey to unrelatedValue, + ) + + val result = migration.migrate(prefs) + + assertThat(result[unrelatedKey]).isEqualTo(unrelatedValue) + } + + // endregion + + // region helpers + + private suspend fun migrateLastSwapped(json: String): String? { + val prefs = mutablePreferencesOf(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to json) + val result = migration.migrate(prefs) + return result[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY] + } + + private suspend fun migrateSwapTransactions(json: String): String? { + val prefs = mutablePreferencesOf(PreferencesKeys.SWAP_TRANSACTIONS_KEY to json) + val result = migration.migrate(prefs) + return result[PreferencesKeys.SWAP_TRANSACTIONS_KEY] + } + + private fun currencyIdJson(id: String): String = "[${currencyIdValue(id)}]" + + private fun currencyIdValue(id: String): String = "{\"cryptoCurrencyId\":\"$id\"}" + + private companion object { + const val BS = '\u27E8' // ⟨ body start + const val BE = '\u27E9' // ⟩ body end + const val DP = '\u2192' // → derivation path delimiter + const val CA = '\u2693' // ⚓ contract address delimiter + } + + // endregion +} \ No newline at end of file diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 3020e44ee4..c2e83ab662 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -17,5 +17,4 @@ dependencies { kapt(deps.hilt.kapt) implementation(deps.material) - implementation(deps.reKotlin) } \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 41c4f24cdb..a67d74a936 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -150,6 +150,9 @@ Nicht mehr anzeigen Verstanden Guthaben sind ausgeblendet + Swap starten + Tauschen Sie ein Asset gegen ein anderes – in wenigen Schritten + Führen Sie Ihren ersten Swap durch Laut den Blockchain-Entwicklern befinden sich der Kaspa-Token derzeit in der Betaphase. Bleibe dran für Updates! Beta-Phase Die Biometrie ist auf Deinem Gerät deaktiviert, daher kannst Du sie nicht zum Entsperren Deiner Wallets verwenden. Aktiviere die Biometrie in den Geräteeinstellungen, um diese Methode wieder nutzen zu können. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index f7bc36a8d3..9fd0d37f78 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -146,6 +146,9 @@ No mostrar de nuevo Entendido Los saldos están ocultos + Iniciar intercambio + Convierte un activo en otro con solo unos toques + Realiza tu primer intercambio Según los desarrolladores de la blockchain, los tokens de Kaspa se encuentran actualmente en fase beta. ¡Estén atentos a las actualizaciones! Modo Beta La biometría está desactivada en su dispositivo, por lo que no puede utilizarla para desbloquear sus billeteras. Active la biometría en los ajustes de su dispositivo para volver a utilizar este método. diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 8faa21a926..a56d16083d 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -146,6 +146,9 @@ Ne plus afficher Compris Les soldes sont masqués + Lancer l\'échange + Convertissez un actif en un autre en quelques touches + Effectuez votre premier échange Selon les développeurs de la blockchain, les jetons Kaspa sont actuellement en version bêta. Restez à l\'écoute des mises à jour ! Mode bêta La biométrie est désactivée sur votre appareil, vous ne pouvez donc pas l\'utiliser pour déverrouiller vos portefeuilles. Activez la biométrie dans les paramètres de votre appareil pour pouvoir à nouveau utiliser cette méthode. diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 1b7dd158fb..5291fdefb6 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -3,6 +3,9 @@ Default Legacy Questa carta non è progettata per funzionare con Tangem + Avvia lo scambio + Converti un asset in un altro con pochi tocchi + Esegui il tuo primo scambio L\'importo inviato e il cambio non può essere inferiore a 1 ADA Accetta Importo diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 01f8d724db..cefc401934 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -149,6 +149,9 @@ 今後表示しない わかりました 残高は非表示 + スワップを開始 + 数タップで1つの資産を別の資産に交換できます + はじめてのスワップ ブロックチェーン開発者によると、Kaspaトークンは現在ベータ版です。アップデートをお楽しみに! ベータモード この端末では生体認証がオフになっているため、ウォレットの解除に使用できません。再度この方法を使用するには、端末の設定で生体認証を有効にしてください。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 45dbb43d03..7a017e2a39 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -150,6 +150,9 @@ Não mostrar novamente Entendi Os saldos estão ocultos. + Iniciar swap + Converta um ativo em outro com apenas alguns toques + Faça seu primeiro swap Segundo os desenvolvedores da blockchain, os tokens Kaspa estão atualmente em fase beta. Fique atento para mais novidades! Modo Beta A biometria está desativada no seu dispositivo, portanto, você não pode usá-la para desbloquear suas carteiras. Ative a biometria nas configurações do seu dispositivo para usar esse método novamente. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 9582985aa6..6d011848ae 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -152,6 +152,9 @@ Больше не показывать Понятно Балансы скрыты + Начать обмен + Превратите один актив в другой всего за несколько касаний + Совершите первый обмен Согласно информации от разработчиков сети, токены Kaspa находятся в режиме бета. Следите за обновлениями! Бета режим Биометрия отключена на вашем устройстве, поэтому вы не можете использовать её для разблокировки кошельков. Включите биометрию в настройках устройства, чтобы снова использовать этот способ. diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 0e1fecd93d..ec58da954a 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -148,6 +148,9 @@ Більше не показувати Зрозуміло Баланси приховані + Почати обмін + Перетворіть один актив на інший лише кількома дотиками + Здійсніть перший обмін Згідно інформації від розробників мережі, токени Kaspa знаходяться у режимі бета. Слідкуйте за оновленнями! Бета режим Біометрія на вашому пристрої вимкнена, тому ви не можете використовувати її для розблокування гаманців. Увімкніть біометрію в налаштуваннях пристрою, щоб знову використовувати цей метод. diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 336648fd49..4cdf3bc127 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -149,6 +149,9 @@ 不要再次显示 明白 余额已隐藏 + 开始兑换 + 完成您的首次兑换 + 完成您的首次兑换 据区块链开发者称,Kaspa代币目前处于测试阶段。敬请关注后续更新! 测试模式 您的设备已关闭生物识别功能,因此无法使用此功能解锁钱包。请在设备设置中启用生物识别功能,即可再次使用此方法。 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 69ae0df956..5cc5b00128 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -22,6 +22,9 @@ 將錢包保存在應用程序中 啟用以將所有錢包鏈接到 Tangem 應用程序。解鎖應用程序需要生物識別身份驗證。交易簽名需要輕觸您的 Tangem 卡片 APP設置 + 開始兌換 + 完成您的首次兌換 + 完成您的首次兌換 請掃描卡片 請30秒後重試或刷卡 嘗試次數過多 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index b4c828117b..40a4a3dba0 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -517,6 +517,7 @@ Funds were found on additional addresses. Enable Dynamic Addresses to access them. Funds found on additional addresses Dynamic address + Dynamic addresses management will be available once the pending transaction(s) in network %@ is complete Best opportunities Clear filter The list is temporarily empty as it’s being refreshed. Check back in a moment. @@ -1116,6 +1117,12 @@ This transaction has already been processed. No further action is required. Fetching best rates... Instant + Verification is free and usually takes 1-2 minutes + Tangem won\'t have access to your identity information, you share data directly with regulated provider + Verification unlocks full access to future transactions with this provider + Choose another method + To comply with local regulatory requirements %@ requires identity verification. + Identity verification required by payment provider By using onramp functionality, you agree with provider’s %1$s and %2$s Service is provided by an external provider.\nTangem is not responsible. The purchase amount should be no more than %s diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 87b86b8135..10573a0d9d 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,9 +1,75 @@ +import java.security.MessageDigest + plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) id("configuration") } +/** + * Verifies that generated Kotlin token files match the current ds-tokens submodule. + * If this fails, run: cd core/ui/token-gen && npm run build + */ +abstract class VerifyDesignTokensTask : DefaultTask() { + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val tokensDir: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val hashFile: RegularFileProperty + + @get:OutputFile + abstract val stampFile: RegularFileProperty + + @TaskAction + fun verify() { + val hashFileValue = hashFile.get().asFile + require(hashFileValue.exists()) { + "Design tokens hash file not found: ${hashFileValue.absolutePath}\n" + + "Run the token generator: cd core/ui/token-gen && npm run build" + } + + val tokensDirValue = tokensDir.get().asFile + require(tokensDirValue.exists() && tokensDirValue.isDirectory) { + "ds-tokens submodule not found: ${tokensDirValue.absolutePath}\n" + + "Run: git submodule update --init --recursive" + } + + val digest = MessageDigest.getInstance("SHA-256") + val jsonFiles = tokensDirValue.walkTopDown() + .filter { it.isFile && it.extension == "json" } + .sortedBy { it.relativeTo(tokensDirValue).path } + .toList() + + val nul = byteArrayOf(0) + for (file in jsonFiles) { + digest.update(file.relativeTo(tokensDirValue).invariantSeparatorsPath.toByteArray()) + digest.update(nul) + digest.update(file.readBytes()) + digest.update(nul) + } + + val actual = digest.digest() + .joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') } + val expected = hashFileValue.readText().trim() + + require(actual == expected) { + "Design tokens are out of date!\n" + + " ds-tokens hash: $actual\n" + + " generated hash: $expected\n" + + "Run the token generator: cd core/ui/token-gen && npm run build" + } + + stampFile.get().asFile.writeText(actual) + } +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + android { namespace = "com.tangem.core.ui" @@ -15,6 +81,16 @@ android { } } +val verifyDesignTokens = tasks.register("verifyDesignTokens") { + tokensDir.set(file("ds-tokens/tokens")) + hashFile.set(file("src/main/java/com/tangem/core/ui/res/generated/.tokens-hash")) + stampFile.set(layout.buildDirectory.file("tokens-verified.stamp")) +} + +tasks.named("preBuild") { + dependsOn(verifyDesignTokens) +} + dependencies { /** Project - Domain */ implementation(projects.domain.appTheme.models) @@ -77,4 +153,5 @@ dependencies { testImplementation(deps.test.truth) testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) + testRuntimeOnly(deps.test.junit5.vintage.engine) } \ No newline at end of file diff --git a/core/ui/ds-tokens b/core/ui/ds-tokens new file mode 160000 index 0000000000..27202508b6 --- /dev/null +++ b/core/ui/ds-tokens @@ -0,0 +1 @@ +Subproject commit 27202508b606f54c276afa577a2f3e7a3da27e8b diff --git a/core/ui/src/main/java/com/tangem/core/ui/HoldToConfirmButtonFeatureToggles.kt b/core/ui/src/main/java/com/tangem/core/ui/HoldToConfirmButtonFeatureToggles.kt deleted file mode 100644 index 35efae6bc9..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/HoldToConfirmButtonFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.core.ui - -interface HoldToConfirmButtonFeatureToggles { - val isHoldToConfirmEnabled: Boolean -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt index fcc03b10e6..709b5a6625 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt @@ -25,13 +25,17 @@ import dev.chrisbanes.haze.HazeTint * elements and floating button at the bottom of the screen. */ @Composable -fun BottomFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemTheme.colors.background.secondary) { +fun BottomFade( + modifier: Modifier = Modifier, + backgroundColor: Color = TangemTheme.colors.background.secondary, + height: Dp = 100.dp, +) { val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } Box( modifier = modifier .fillMaxWidth() - .height(TangemTheme.dimens.size100 + bottomBarHeight) + .height(height + bottomBarHeight) .background( brush = Brush.verticalGradient( colors = listOf( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt index 939eac7407..38674b5788 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -30,7 +30,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall + Default, Large, Medium, Small, ExtraSmall, RedesignedDefault } /** @@ -128,6 +128,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M AccountIconSize.Medium -> TangemTheme.typography.subtitle1 AccountIconSize.Small -> TangemTheme.typography.subtitle2 AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 + AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28 } val textSize by animateFloatAsState( @@ -159,6 +160,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) { AccountIconSize.Medium -> 16.dp AccountIconSize.Small -> 12.dp AccountIconSize.ExtraSmall -> 8.dp + AccountIconSize.RedesignedDefault -> 20.dp } private fun AccountIconSize.boxSizeInDp(): Dp = when (this) { @@ -167,6 +169,7 @@ private fun AccountIconSize.boxSizeInDp(): Dp = when (this) { AccountIconSize.Medium -> 28.dp AccountIconSize.Small -> 20.dp AccountIconSize.ExtraSmall -> 14.dp + AccountIconSize.RedesignedDefault -> 40.dp } private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { @@ -175,6 +178,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { AccountIconSize.Medium -> 8.dp AccountIconSize.Small -> 6.dp AccountIconSize.ExtraSmall -> 4.dp + AccountIconSize.RedesignedDefault -> 12.dp } @Preview(showBackground = true) @@ -203,6 +207,7 @@ private fun Sample() { AccountIconSize.Medium -> AccountIconSize.Small AccountIconSize.Small -> AccountIconSize.ExtraSmall AccountIconSize.ExtraSmall -> AccountIconSize.Default + AccountIconSize.RedesignedDefault -> AccountIconSize.Large } }) { Text("Change") } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt index 49752f2700..6d19073670 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt @@ -6,12 +6,14 @@ import androidx.compose.material3.CardColors import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape import com.tangem.core.ui.res.TangemTheme @Composable fun BlockCard( modifier: Modifier = Modifier, enabled: Boolean = true, + shape: Shape = TangemTheme.shapes.roundedCornersXMedium, colors: CardColors = TangemBlockCardColors, onClick: () -> Unit = {}, content: @Composable ColumnScope.() -> Unit = {}, @@ -19,7 +21,7 @@ fun BlockCard( Card( modifier = modifier, onClick = onClick, - shape = TangemTheme.shapes.roundedCornersXMedium, + shape = shape, colors = colors, enabled = enabled, content = content, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index 60b4d4699a..ce93e4cc75 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -10,10 +10,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -36,6 +37,14 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.WindowInsetsZero +/** + * Extra bottom inset that scrollable content under a [BasicBottomSheet] should reserve so it + * does not collide with the footer overlay: measured footer height plus the bottom gradient + * (see `gradientHeight` in [FooterOverlay]). Equals `0.dp` when there is no footer or when read + * outside [BasicBottomSheet]. + */ +val LocalTangemBottomSheetContentBottomInset = compositionLocalOf { 0.dp } + /** * Type of [TangemBottomSheet] that defines its behavior and appearance. * - [Default]: Standard bottom sheet with a draggable header @@ -204,6 +213,9 @@ inline fun BasicBottomSheet( ) { val model = config.content as? T ?: return val windowSize = LocalWindowSize.current + val density = LocalDensity.current + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + var footerHeightDp by remember { mutableStateOf(null) } val bsContent: @Composable ColumnScope.() -> Unit = { val maxHeight = when (type) { @@ -212,17 +224,19 @@ inline fun BasicBottomSheet( } val contentModifier = when (type) { - Default -> Modifier.clip( - RoundedCornerShape( - topStart = TangemTheme.dimens2.x8, - topEnd = TangemTheme.dimens2.x8, - ), - ) + Default -> Modifier + .padding(bottom = bottomBarHeight) + .clip( + RoundedCornerShape( + topStart = TangemTheme.dimens2.x8, + topEnd = TangemTheme.dimens2.x8, + ), + ) Modal -> Modifier .padding( start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x2, - bottom = TangemTheme.dimens2.x2, + bottom = bottomBarHeight, ) .clip(RoundedCornerShape(TangemTheme.dimens2.x8)) } @@ -236,26 +250,19 @@ inline fun BasicBottomSheet( title(model) } Box(modifier = Modifier.fillMaxWidth()) { - content(model) if (footer != null) { - BottomFade( - modifier = Modifier.align(Alignment.BottomCenter), - gradientBrush = Brush.verticalGradient( - listOf( - TangemTheme.colors2.shadow.fadeMin, - TangemTheme.colors2.shadow.fadeMax, - ), - ), + FooterOverlay( + measuredFooterHeight = footerHeightDp, + onMeasureFooter = { newHeight -> + if (newHeight != footerHeightDp) { + footerHeightDp = newHeight + } + }, + footer = { footer(model) }, + content = { content(model) }, ) - } - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - ) { - if (footer != null) { - footer(model) - } + } else { + content(model) } } } @@ -274,6 +281,57 @@ inline fun BasicBottomSheet( ) } +@Composable +fun BoxScope.FooterOverlay( + measuredFooterHeight: Dp?, + onMeasureFooter: (Dp) -> Unit, + footer: @Composable BoxScope.() -> Unit, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val gradientHeight = TangemTheme.dimens2.x10 + val isFooterRendered = measuredFooterHeight == null || measuredFooterHeight > 0.dp + val contentBottomOverlayHeight = if (isFooterRendered) { + (measuredFooterHeight ?: 0.dp) + gradientHeight + } else { + 0.dp + } + val fadeMax = TangemTheme.colors2.surface.level2 + CompositionLocalProvider( + LocalTangemBottomSheetContentBottomInset provides contentBottomOverlayHeight, + ) { + content() + } + if (isFooterRendered) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { + Fade( + backgroundColor = fadeMax, + height = gradientHeight, + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(measuredFooterHeight ?: 0.dp) + .background(fadeMax), + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .onGloballyPositioned { coordinates -> + onMeasureFooter(with(density) { coordinates.size.height.toDp() }) + }, + ) { + footer() + } +} + // region Preview @Suppress("LongMethod") @Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt index c32033908e..9d5d8700a5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt @@ -216,7 +216,15 @@ private fun ButtonsContainer( } ?: TangemButtonIconPosition.None TangemButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag( + if (button.isPrimary) { + WarningBottomSheetTestTags.BUTTON_PRIMARY + } else { + WarningBottomSheetTestTags.BUTTON_SECONDARY + }, + ), text = button.text?.resolveReference().orEmpty(), icon = icon, onClick = { button.onClick?.invoke(closeScope) }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt index 3ee514559a..9e5c8386c2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -58,6 +59,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.BaseButtonTestTags import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -145,6 +147,7 @@ internal fun TangemHoldToConfirmButton( Surface( modifier = modifier .heightIn(min = buttonHeight) + .testTag(BaseButtonTestTags.BUTTON) .graphicsLayer { scaleX = state.scaleProgress.value scaleY = state.scaleProgress.value @@ -442,10 +445,12 @@ private fun HoldToConfirmButtonContent( ) } else { Text( - modifier = Modifier.graphicsLayer { - translationX = state.shakeOffset.value - alpha = state.textAlpha.value - }, + modifier = Modifier + .testTag(BaseButtonTestTags.TEXT) + .graphicsLayer { + translationX = state.shakeOffset.value + alpha = state.textAlpha.value + }, text = if (state.isHintVisible) textConfig.hintText else textConfig.text, style = textConfig.style, color = color.contentColor, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt index 27dceeec39..5927f3e422 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.painterResource import com.tangem.core.ui.R import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountResIcon +import com.tangem.core.ui.components.account.PaymentAccountIcon import com.tangem.core.ui.components.currency.DefaultCurrencyIcon import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -61,6 +62,7 @@ internal fun ContentIcon( background = icon.background, alpha = alpha, ) + is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(modifier = modifier, size = icon.size) is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( modifier = modifier, resId = icon.resId, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index 9ec3a73b8d..c0e1f4704f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -42,10 +42,11 @@ fun CurrencyIcon( withFixedSize: Boolean = true, iconSize: Dp = 36.dp, ) { + val outerSize = iconSize + 4.dp Box( modifier = modifier .conditional(withFixedSize) { - size(size = 40.dp) + size(size = outerSize) }, ) { val iconModifier = Modifier @@ -62,6 +63,7 @@ fun CurrencyIcon( is CurrencyIconState.FiatIcon, is CurrencyIconState.CustomTokenIcon, is CurrencyIconState.TokenIcon, + is CurrencyIconState.PaymentAccount, is CurrencyIconState.CryptoPortfolio.Icon, is CurrencyIconState.CryptoPortfolio.Letter, -> { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 5bdd5f41f4..505e0d5b67 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -88,6 +88,12 @@ sealed class CurrencyIconState { override val topBadgeIconResId: Int? = null } + data class PaymentAccount(val size: AccountIconSize = AccountIconSize.Default) : CurrencyIconState() { + override val isGrayscale: Boolean = false + override val shouldShowCustomBadge: Boolean = false + override val topBadgeIconResId: Int? = null + } + @Immutable sealed class CryptoPortfolio : CurrencyIconState() { override val shouldShowCustomBadge: Boolean = false @@ -155,6 +161,7 @@ sealed class CurrencyIconState { is CryptoPortfolio.Letter -> copy( isGrayscale = isGrayscale, ) + is PaymentAccount, is Loading, is Locked, is Empty, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt index 158c768fe8..6050d96cb3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt @@ -9,4 +9,5 @@ data class SearchBarUM( val isActive: Boolean, val onActiveChange: (Boolean) -> Unit, val onClearClick: () -> Unit = {}, + val onCancelClick: (() -> Unit)? = null, ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt index 1dea3f423a..fb2f6005b4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -30,6 +31,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags /** * [Input Row Best Rate](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-889&mode=dev) @@ -61,7 +63,8 @@ fun InputRowBestRate( Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(TangemTheme.dimens.spacing12), + .padding(TangemTheme.dimens.spacing12) + .testTag(TransactionSuccessScreenTestTags.PROVIDER_BLOCK), ) { InputRowAsyncImage(imageUrl = imageUrl, modifier = Modifier.size(TangemTheme.dimens.spacing40)) Column( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt index d816b39eb6..98b7616634 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt @@ -45,7 +45,7 @@ fun PasteButton( ) { val clipboardManager = LocalClipboardManager.current val hapticFeedback = LocalHapticFeedback.current - val isPasteEnabled = !clipboardManager.getText()?.text.isNullOrEmpty() + val isPasteEnabled = clipboardManager.hasText() val color = if (isPasteEnabled) { backgroundColorEnabled } else { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt index 2841c1c57e..a3dd844b63 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt @@ -127,13 +127,13 @@ private fun DescriptionItemV2( onClick = onReadMoreClick, ), text = text, - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, ) } else { Text( modifier = modifier, text = description.resolveReference(), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.tertiary, ) } @@ -179,17 +179,17 @@ private fun DescriptionPlaceholderV2(modifier: Modifier = Modifier) { ) { TextShimmer( modifier = Modifier.fillMaxWidth(), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, radius = TangemTheme.dimens2.x25, ) TextShimmer( modifier = Modifier.fillMaxWidth(), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, radius = TangemTheme.dimens2.x25, ) TextShimmer( modifier = Modifier.fillMaxWidth(fraction = 0.8f), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, radius = TangemTheme.dimens2.x25, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt index 92785ec693..dc99632334 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt @@ -121,6 +121,7 @@ private fun PriceBlock(state: MarketPriceBlockState, priceWidthDp: Dp) { PriceChangeInPercent( valueInPercent = marketPriceBlockState.priceChangeConfig.valueInPercent, type = marketPriceBlockState.priceChangeConfig.type, + textStyle = TangemTheme.typography.body2, ) } } else { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt index a202d8bf21..c4882dcdc8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -13,15 +14,43 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign @Composable fun PriceChangeInPercent( valueInPercent: String, type: PriceChangeType, + textStyle: TextStyle, + modifier: Modifier = Modifier, + isDisabled: Boolean = false, +) { + if (LocalRedesignEnabled.current) { + PriceChangeInPercentV2( + modifier = modifier, + valueInPercent = valueInPercent, + type = type, + textStyle = textStyle, + isDisabled = isDisabled, + ) + } else { + PriceChangeInPercentV1( + modifier = modifier, + valueInPercent = valueInPercent, + type = type, + textStyle = textStyle, + ) + } +} + +@Composable +private fun PriceChangeInPercentV1( + valueInPercent: String, + type: PriceChangeType, + textStyle: TextStyle, modifier: Modifier = Modifier, - textStyle: TextStyle = TangemTheme.typography.body2, ) { if (valueInPercent.isBlank()) { Box(modifier) @@ -66,25 +95,87 @@ fun PriceChangeInPercent( } } +@Composable +private fun PriceChangeInPercentV2( + valueInPercent: String, + type: PriceChangeType, + textStyle: TextStyle, + isDisabled: Boolean, + modifier: Modifier = Modifier, +) { + if (valueInPercent.isBlank()) { + Box(modifier) + return + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens2.x0_5), + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens2.x3) + .align(Alignment.CenterVertically), + imageVector = ImageVector.vectorResource( + id = when (type) { + PriceChangeType.UP -> R.drawable.ic_arrow_up_8 + PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 + PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 + }, + ), + tint = if (isDisabled) { + TangemTheme.colors2.graphic.neutral.tertiary + } else { + when (type) { + PriceChangeType.UP -> TangemTheme.colors2.markers.iconBlue + PriceChangeType.DOWN -> TangemTheme.colors2.markers.iconRed + PriceChangeType.NEUTRAL -> TangemTheme.colors2.markers.iconGray + } + }, + contentDescription = null, + ) + + Text( + text = valueInPercent, + color = if (isDisabled) { + TangemTheme.colors2.text.status.disabled + } else { + when (type) { + PriceChangeType.UP -> TangemTheme.colors2.text.status.accent + PriceChangeType.DOWN -> TangemTheme.colors2.text.status.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors2.text.neutral.tertiary + } + }, + style = textStyle, + overflow = TextOverflow.Visible, + maxLines = 1, + ) + } +} + //region Preview @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview() { +private fun PreviewV1() { TangemThemePreview { Column { PriceChangeInPercent( valueInPercent = "52.00%", type = PriceChangeType.NEUTRAL, + textStyle = TangemTheme.typography.body2, ) PriceChangeInPercent( valueInPercent = "52.00%", type = PriceChangeType.UP, + textStyle = TangemTheme.typography.body2, ) PriceChangeInPercent( valueInPercent = "52.00%", type = PriceChangeType.DOWN, + textStyle = TangemTheme.typography.body2, ) PriceChangeInPercent( valueInPercent = "52.00%", @@ -95,4 +186,38 @@ private fun Preview() { } } +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewV2() { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Column { + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.NEUTRAL, + textStyle = TangemTheme.typography2.captionRegular12, + ) + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.UP, + textStyle = TangemTheme.typography2.captionRegular12, + isDisabled = true, + ) + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.DOWN, + textStyle = TangemTheme.typography2.captionRegular12, + isDisabled = false, + ) + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.DOWN, + textStyle = TangemTheme.typography2.captionRegular12, + ) + } + } + } +} + //endregion Preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt index 8db483f50c..768c0696cc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt @@ -1,8 +1,11 @@ package com.tangem.core.ui.components.marketprice -sealed class PriceChangeState { +import androidx.compose.runtime.Immutable - data class Content(val valueInPercent: String, val type: PriceChangeType) : PriceChangeState() +@Immutable +sealed interface PriceChangeState { - object Unknown : PriceChangeState() + data class Content(val valueInPercent: String, val type: PriceChangeType) : PriceChangeState + + data object Unknown : PriceChangeState } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt index d821328360..3bc40e3242 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt @@ -1,11 +1,7 @@ package com.tangem.core.ui.components.token.internal import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -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.Composable @@ -13,11 +9,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag -import com.tangem.core.ui.test.TokenElementsTestTags import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach @@ -25,7 +21,10 @@ import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.TokenElementsTestTags +import kotlinx.collections.immutable.ImmutableList import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState @Composable @@ -60,6 +59,11 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool // Empty box for proper measurements Box(modifier) } + is TokenFiatAmountState.AnnotatedContent -> FiatAmountAnnotatedText( + text = state.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + modifier = modifier, + isFlickering = state.isFlickering, + ) null -> Unit } } @@ -77,7 +81,7 @@ private fun IconAmount(state: TokenFiatAmountState.Icon, modifier: Modifier = Mo @Composable private fun ContentFiatAmount( text: String, - icons: List, + icons: ImmutableList, isAmountFlickering: Boolean, modifier: Modifier = Modifier, ) { @@ -135,6 +139,25 @@ private fun FiatAmountText( ) } +@Composable +private fun FiatAmountAnnotatedText( + text: AnnotatedString, + modifier: Modifier = Modifier, + isAvailable: Boolean = true, + isFlickering: Boolean = false, +) { + Text( + modifier = modifier, + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography.body2.applyBladeBrush( + isEnabled = isFlickering, + textColor = if (isAvailable) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.tertiary, + ), + ) +} + private fun Modifier.placeholderSize(): Modifier = composed { return@composed this .padding(vertical = 4.dp) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt index beec4ce551..bb3d8c4b4f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt @@ -230,6 +230,11 @@ sealed class TokenItemState { val tint: IconTint = IconTint.Inactive, ) : FiatAmountState() + data class AnnotatedContent( + val text: TextReference, + val isFlickering: Boolean = false, + ) : FiatAmountState() + data object Loading : FiatAmountState() data object Locked : FiatAmountState() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt index 0c6291b831..7ae2238fb7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt @@ -2,7 +2,8 @@ package com.tangem.core.ui.components.tokenlist import androidx.compose.animation.* import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds -import androidx.compose.animation.core.* +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon @@ -24,6 +25,7 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.account.AccountResIcon +import com.tangem.core.ui.components.account.PaymentAccountIcon import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar @@ -98,6 +100,9 @@ fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolea icon.copy( size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default, ) + is CurrencyIconState.PaymentAccount -> icon.copy( + size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default, + ) else -> icon } @@ -183,7 +188,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B } } -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun ExpandedPortfolioHeader( state: TokenItemState, @@ -209,6 +214,7 @@ fun ExpandedPortfolioHeader( composables.icon.invoke(Modifier) } else { when (val icon = state.iconState) { + is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(size = icon.size) is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( resId = icon.resId, color = icon.color, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt index c09e64697b..f5c2277c35 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt @@ -55,6 +55,12 @@ sealed interface TokensListItemUM { is PortfolioItemContentUM.Tokens -> content.tokens is PortfolioItemContentUM.Empty -> persistentListOf() } + + val tokensItemsList: List + get() = when (content) { + is PortfolioItemContentUM.Tokens -> content.tokens.filterIsInstance() + is PortfolioItemContentUM.Empty -> emptyList() + } } data class Text(override val id: Any, val text: TextReference) : TokensListItemUM diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt index 0a5cc124d1..584f323bf4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -21,6 +22,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags /** * Common transaction done screen title @@ -46,7 +48,8 @@ fun TransactionDoneTitle(title: TextReference, subtitle: TextReference, modifier style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, modifier = Modifier - .padding(top = TangemTheme.dimens.spacing16), + .padding(top = TangemTheme.dimens.spacing16) + .testTag(TransactionSuccessScreenTestTags.TITLE), ) Text( text = subtitle.resolveReference(), @@ -54,7 +57,8 @@ fun TransactionDoneTitle(title: TextReference, subtitle: TextReference, modifier color = TangemTheme.colors.text.tertiary, textAlign = TextAlign.Center, modifier = Modifier - .padding(top = TangemTheme.dimens.spacing4), + .padding(top = TangemTheme.dimens.spacing4) + .testTag(TransactionSuccessScreenTestTags.TRANSACTION_DATE), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt index b5c5335aba..d1b253f7d9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt @@ -14,7 +14,8 @@ import androidx.compose.ui.util.fastForEach import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey -import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock +import com.tangem.core.ui.R +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlockLegacy import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.components.transactions.state.TxHistoryState @@ -45,13 +46,21 @@ fun LazyListScope.txHistoryItems( ) } is TxHistoryState.Empty -> { - nonContentItem(state = EmptyTransactionsBlockState.Empty(state.onExploreClick), modifier = modifier) + nonContentItem( + state = EmptyTransactionsBlockState.Empty( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + modifier = modifier, + ) } is TxHistoryState.Error -> { nonContentItem( state = EmptyTransactionsBlockState.FailedToLoad( onReload = state.onReloadClick, onExplore = state.onExploreClick, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_arrow_top_right_24, ), modifier = modifier, ) @@ -64,7 +73,10 @@ fun LazyListScope.txHistoryItems( } nonContentItem( - state = EmptyTransactionsBlockState.NotImplemented(onExplore = state.onExploreClick), + state = EmptyTransactionsBlockState.NotImplemented( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), modifier = modifier, ) } @@ -121,7 +133,7 @@ fun PendingTxsBlock(pendingTxs: ImmutableList, isBalanceHidden private fun LazyListScope.nonContentItem(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { item(key = state::class.java, contentType = state::class.java) { - EmptyTransactionBlock( + EmptyTransactionBlockLegacy( state = state, modifier = modifier .animateItem(fadeInSpec = null, fadeOutSpec = null) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt index dfb6f1339c..0b672e80b2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt @@ -1,65 +1,74 @@ package com.tangem.core.ui.components.transactions.empty import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.buttons.actions.ActionButton +import com.tangem.core.ui.R +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.ds.button.TangemButton +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.EmptyTransactionBlockTestTags -/** - * Placeholder for transaction's block without content - * - * @param state component state - * @param modifier modifier - */ @Composable fun EmptyTransactionBlock(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { Column( modifier = modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(color = TangemTheme.colors.background.primary) - .padding(vertical = TangemTheme.dimens.spacing24) + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x6) .testTag(EmptyTransactionBlockTestTags.BLOCK), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + iconRes = state.iconRes, + tintReference = { TangemTheme.colors2.graphic.neutral.tertiary }, + ), modifier = Modifier - .size(TangemTheme.dimens.size64) + .size(TangemTheme.dimens2.x16) .testTag(EmptyTransactionBlockTestTags.ICON), - painter = painterResource(id = state.iconRes), - tint = TangemTheme.colors.icon.inactive, - contentDescription = null, ) + Spacer(modifier = Modifier.height(TangemTheme.dimens2.x4)) + Text( modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing32) + .padding(horizontal = TangemTheme.dimens2.x8) .testTag(EmptyTransactionBlockTestTags.TEXT), textAlign = TextAlign.Center, text = state.text.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography2.calloutRegular15, + color = TangemTheme.colors2.text.neutral.tertiary, ) + Spacer(modifier = Modifier.height(TangemTheme.dimens2.x8)) + Buttons( modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing18) + .padding(horizontal = TangemTheme.dimens2.x4) .testTag(EmptyTransactionBlockTestTags.EXPLORE_BUTTON), state = state.buttonsState, ) @@ -76,41 +85,70 @@ private fun Buttons(state: EmptyTransactionsBlockState.ButtonsState, modifier: M @Composable private fun SingleButton(state: EmptyTransactionsBlockState.ButtonsState.SingleButton, modifier: Modifier = Modifier) { - ActionButton(modifier = modifier, config = state.actionButtonConfig) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + ) { + TangemButton(buttonUM = state.actionButtonConfig.toButtonUM()) + } } @Composable private fun PairButtons(state: EmptyTransactionsBlockState.ButtonsState.PairButtons, modifier: Modifier = Modifier) { Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), ) { - ActionButton( + TangemButton( modifier = Modifier.weight(1F), - config = state.firstButtonConfig, + buttonUM = state.firstButtonConfig.toButtonUM(), ) - ActionButton( + TangemButton( modifier = Modifier.weight(1F), - config = state.secondButtonConfig, + buttonUM = state.secondButtonConfig.toButtonUM(), ) } } +private fun ActionButtonConfig.toButtonUM(): TangemButtonUM = TangemButtonUM( + text = text, + tangemIconUM = TangemIconUM.Icon(iconRes = iconResId), + type = TangemButtonType.Secondary, + size = TangemButtonSize.X12, + isEnabled = isEnabled, + isLoading = isInProgress, + onClick = onClick, + shape = TangemButtonShape.Rounded, + iconPosition = TangemButtonIconPosition.End, +) + @Composable @Preview(widthDp = 360, showBackground = true) @Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun EmptyTransactionBlockPreview( @PreviewParameter(EmptyTransactionBlockStateProvider::class) state: EmptyTransactionsBlockState, ) { - TangemThemePreview { + TangemThemePreviewRedesign { EmptyTransactionBlock(state = state) } } -private class EmptyTransactionBlockStateProvider : CollectionPreviewParameterProvider( - collection = listOf( - EmptyTransactionsBlockState.Empty {}, - EmptyTransactionsBlockState.FailedToLoad(onReload = {}, onExplore = {}), - EmptyTransactionsBlockState.NotImplemented(onExplore = {}), - ), -) \ No newline at end of file +private class EmptyTransactionBlockStateProvider : + CollectionPreviewParameterProvider( + collection = listOf( + EmptyTransactionsBlockState.Empty( + onExplore = {}, + exploreIconResId = R.drawable.ic_compass_24, + ), + EmptyTransactionsBlockState.FailedToLoad( + onReload = {}, + onExplore = {}, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_compass_24, + ), + EmptyTransactionsBlockState.NotImplemented( + onExplore = {}, + exploreIconResId = R.drawable.ic_compass_24, + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlockLegacy.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlockLegacy.kt new file mode 100644 index 0000000000..c4cfe226f2 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlockLegacy.kt @@ -0,0 +1,129 @@ +package com.tangem.core.ui.components.transactions.empty + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.R +import com.tangem.core.ui.components.buttons.actions.ActionButton +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.EmptyTransactionBlockTestTags + +/** + * Placeholder for transaction's block without content + * + * @param state component state + * @param modifier modifier + */ +@Composable +fun EmptyTransactionBlockLegacy(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(color = TangemTheme.colors.background.primary) + .padding(vertical = TangemTheme.dimens.spacing24) + .testTag(EmptyTransactionBlockTestTags.BLOCK), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .testTag(EmptyTransactionBlockTestTags.ICON), + painter = painterResource(id = state.iconRes), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + ) + + Text( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing32) + .testTag(EmptyTransactionBlockTestTags.TEXT), + textAlign = TextAlign.Center, + text = state.text.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + Buttons( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing18) + .testTag(EmptyTransactionBlockTestTags.EXPLORE_BUTTON), + state = state.buttonsState, + ) + } +} + +@Composable +private fun Buttons(state: EmptyTransactionsBlockState.ButtonsState, modifier: Modifier = Modifier) { + when (state) { + is EmptyTransactionsBlockState.ButtonsState.SingleButton -> SingleButton(state = state, modifier = modifier) + is EmptyTransactionsBlockState.ButtonsState.PairButtons -> PairButtons(state = state, modifier = modifier) + } +} + +@Composable +private fun SingleButton(state: EmptyTransactionsBlockState.ButtonsState.SingleButton, modifier: Modifier = Modifier) { + ActionButton(modifier = modifier, config = state.actionButtonConfig) +} + +@Composable +private fun PairButtons(state: EmptyTransactionsBlockState.ButtonsState.PairButtons, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + ActionButton( + modifier = Modifier.weight(1F), + config = state.firstButtonConfig, + ) + ActionButton( + modifier = Modifier.weight(1F), + config = state.secondButtonConfig, + ) + } +} + +@Composable +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun EmptyTransactionBlockLegacyPreview( + @PreviewParameter(EmptyTransactionBlockLegacyStateProvider::class) state: EmptyTransactionsBlockState, +) { + TangemThemePreview { + EmptyTransactionBlockLegacy(state = state) + } +} + +private class EmptyTransactionBlockLegacyStateProvider : + CollectionPreviewParameterProvider( + collection = listOf( + EmptyTransactionsBlockState.Empty( + onExplore = {}, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + EmptyTransactionsBlockState.FailedToLoad( + onReload = {}, + onExplore = {}, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + EmptyTransactionsBlockState.NotImplemented( + onExplore = {}, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt index 53790df155..a39b110c46 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt @@ -1,5 +1,6 @@ package com.tangem.core.ui.components.transactions.empty +import androidx.annotation.DrawableRes import com.tangem.core.ui.R import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.extensions.TextReference @@ -21,17 +22,19 @@ sealed class EmptyTransactionsBlockState( data class FailedToLoad( val onReload: () -> Unit, val onExplore: () -> Unit, + @DrawableRes val reloadIconResId: Int, + @DrawableRes val exploreIconResId: Int, ) : EmptyTransactionsBlockState( buttonsState = ButtonsState.PairButtons( firstButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_reload), - iconResId = R.drawable.ic_refresh_24, + iconResId = reloadIconResId, onClick = onReload, isEnabled = true, ), secondButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_explore), - iconResId = R.drawable.ic_arrow_top_right_24, + iconResId = exploreIconResId, onClick = onExplore, isEnabled = true, ), @@ -40,11 +43,14 @@ sealed class EmptyTransactionsBlockState( text = TextReference.Res(R.string.transaction_history_error_failed_to_load), ) - data class Empty(val onExplore: (() -> Unit)) : EmptyTransactionsBlockState( + data class Empty( + val onExplore: () -> Unit, + @DrawableRes val exploreIconResId: Int, + ) : EmptyTransactionsBlockState( buttonsState = ButtonsState.SingleButton( actionButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_explore), - iconResId = R.drawable.ic_arrow_top_right_24, + iconResId = exploreIconResId, onClick = onExplore, isEnabled = true, ), @@ -53,11 +59,14 @@ sealed class EmptyTransactionsBlockState( text = TextReference.Res(R.string.transaction_history_empty_transactions), ) - data class NotImplemented(val onExplore: () -> Unit) : EmptyTransactionsBlockState( + data class NotImplemented( + val onExplore: () -> Unit, + @DrawableRes val exploreIconResId: Int, + ) : EmptyTransactionsBlockState( buttonsState = ButtonsState.SingleButton( actionButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_explore_transaction_history), - iconResId = R.drawable.ic_arrow_top_right_24, + iconResId = exploreIconResId, onClick = onExplore, isEnabled = true, ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt index 05fcdc6c3a..e86793d0d1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt @@ -14,20 +14,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -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.dp +import androidx.compose.ui.unit.* +import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -63,6 +63,7 @@ private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp) fun TangemPagerIndicator( pagerState: PagerState, modifier: Modifier = Modifier, + hazeState: HazeState = rememberHazeState(), colors: PagerIndicatorColors = TangemPagerIndicatorColors, ) { val totalPages = pagerState.pageCount @@ -84,10 +85,12 @@ fun TangemPagerIndicator( .width(getSize(totalPages)) .conditionalCompose(colors.overlay != null) { colors.overlay?.let { overlay -> - background( - color = overlay, - shape = CircleShape, - ) + this + .clip(CircleShape) + .hazeEffectTangem(hazeState) { + this.blurRadius = 16.dp + backgroundColor = overlay + } } ?: this } .padding(TangemTheme.dimens2.x3), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt index bc59df4610..89d58c63d9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -46,6 +46,7 @@ fun TangemBadge(badgeUM: TangemBadgeUM, modifier: Modifier = Modifier) { color = badgeUM.color, type = badgeUM.type, iconPosition = badgeUM.iconPosition, + shouldRespectIconTint = badgeUM.shouldRespectIconTint, onClick = badgeUM.onClick, modifier = modifier, ) @@ -77,6 +78,7 @@ fun TangemBadge( color: TangemBadgeColor = TangemBadgeColor.Gray, type: TangemBadgeType = TangemBadgeType.Solid, iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.None, + shouldRespectIconTint: Boolean = false, onClick: (() -> Unit)? = null, ) { val iconColor = getIconColor(type = type, color = color) @@ -95,6 +97,7 @@ fun TangemBadge( iconPosition = iconPosition, size = size, iconColor = iconColor, + shouldRespectIconTint = shouldRespectIconTint, ) AnimatedVisibility( visible = text != null, @@ -113,6 +116,7 @@ fun TangemBadge( iconPosition = iconPosition, size = size, iconColor = iconColor, + shouldRespectIconTint = shouldRespectIconTint, ) } } @@ -123,6 +127,7 @@ private fun StartIcon( size: TangemBadgeSize, iconColor: Color, tangemIconUM: TangemIconUM? = null, + shouldRespectIconTint: Boolean = false, ) { AnimatedVisibility( visible = tangemIconUM != null && iconPosition != TangemBadgeIconPosition.End, @@ -139,7 +144,11 @@ private fun StartIcon( is TangemIconUM.Url, TangemIconUM.Empty, -> wrappedIconRes - is TangemIconUM.Icon -> wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + is TangemIconUM.Icon -> if (shouldRespectIconTint) { + wrappedIconRes + } else { + wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + } }, ) } @@ -151,6 +160,7 @@ private fun EndIcon( size: TangemBadgeSize, iconColor: Color, tangemIconUM: TangemIconUM? = null, + shouldRespectIconTint: Boolean = false, ) { AnimatedVisibility( visible = tangemIconUM != null && iconPosition == TangemBadgeIconPosition.End, @@ -167,7 +177,11 @@ private fun EndIcon( is TangemIconUM.Url, TangemIconUM.Empty, -> wrappedIconRes - is TangemIconUM.Icon -> wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + is TangemIconUM.Icon -> if (shouldRespectIconTint) { + wrappedIconRes + } else { + wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + } }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt index 83843fbc3d..84cff62206 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt @@ -7,14 +7,16 @@ import com.tangem.core.ui.extensions.TextReference /** * UI model for [TangemBadge] component * - * @param text TextReference for the badge label. - * @param tangemIconUM Model of representation for the icon to be displayed in the badge. - * @param size [TangemBadgeSize] defining the size of the badge. - * @param shape [TangemBadgeShape] defining the shape of the badge. - * @param color [TangemBadgeColor] defining the color scheme of the badge. - * @param type [TangemBadgeType] defining the style of the badge. - * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. - * @param onClick Lambda to be invoked when the badge is clicked (optional). + * @param text TextReference for the badge label. + * @param tangemIconUM Model of representation for the icon to be displayed in the badge. + * @param size [TangemBadgeSize] defining the size of the badge. + * @param shape [TangemBadgeShape] defining the shape of the badge. + * @param color [TangemBadgeColor] defining the color scheme of the badge. + * @param type [TangemBadgeType] defining the style of the badge. + * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. + * @param shouldRespectIconTint When true, the icon's own tintReference is preserved instead of being overridden by the + * badge color scheme. Useful when the icon carries its own semantic color (e.g. account icons). + * @param onClick Lambda to be invoked when the badge is clicked (optional). */ class TangemBadgeUM( val text: TextReference, @@ -24,5 +26,6 @@ class TangemBadgeUM( val color: TangemBadgeColor = TangemBadgeColor.Gray, val type: TangemBadgeType = TangemBadgeType.Solid, val iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start, + val shouldRespectIconTint: Boolean = false, val onClick: (() -> Unit)? = null, ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt index 60ac1aebea..7c51476e5b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt @@ -47,7 +47,7 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { iconPosition = buttonUM.iconPosition, isEnabled = buttonUM.isEnabled, isLoading = buttonUM.isLoading, - type = TangemButtonType.Positive, + type = TangemButtonType.Accent, size = buttonUM.size, shape = buttonUM.shape, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt new file mode 100644 index 0000000000..9a88602e17 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt @@ -0,0 +1,129 @@ +package com.tangem.core.ui.ds.button.action + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.orEmpty +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * Action buttons row + * + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=9467-37845&m=dev) + * + * @param buttons list of buttons + * @param modifier modifier + */ +@Composable +fun ActionButtons(buttons: ImmutableList, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + buttons.forEachIndexed { index, button -> + key(button.text to index) { + val textColor = if (button.isEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled + } + Column( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SecondaryTangemButton( + tangemIconUM = button.tangemIconUM, + onClick = button.onClick, + isEnabled = button.isEnabled, + shape = TangemButtonShape.Rounded, + ) + Text( + text = button.text.orEmpty().resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = textColor, + maxLines = 1, + ) + } + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ActionButtons_Preview( + @PreviewParameter(ActionButtonsPreviewProvider::class) params: ImmutableList, +) { + TangemThemePreviewRedesign { + ActionButtons( + buttons = params, + ) + } +} + +private class ActionButtonsPreviewProvider : PreviewParameterProvider> { + override val values: Sequence> + get() = sequenceOf( + persistentListOf( + TangemButtonUM( + text = stringReference("Send"), + tangemIconUM = previewIcon(R.drawable.ic_arrow_up_24, isEnabled = true), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = stringReference("Receive"), + tangemIconUM = previewIcon(R.drawable.ic_exchange_default_24, isEnabled = true), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = stringReference("Swap"), + tangemIconUM = previewIcon(R.drawable.ic_dollar_default_24, isEnabled = false), + onClick = { }, + isEnabled = false, + type = TangemButtonType.Secondary, + ), + ), + ) +} + +private fun previewIcon(iconRes: Int, isEnabled: Boolean): TangemIconUM = TangemIconUM.Icon( + iconRes = iconRes, + tintReference = { + if (isEnabled) { + TangemTheme.colors2.graphic.neutral.primary + } else { + TangemTheme.colors2.graphic.neutral.quaternary + } + }, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt index 4e2e9ebb42..905ef8c5fb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt @@ -289,8 +289,13 @@ private fun CancelButton( state.onQueryChange("") } keyboardController?.hide() - state.onClearClick() - focusManager.clearFocus() + val onCancel = state.onCancelClick + if (onCancel != null) { + onCancel() + } else { + state.onClearClick() + focusManager.clearFocus() + } }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt index 79f093e351..e3750fd53e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt @@ -169,6 +169,7 @@ private fun BoxScope.CurrencyIconBackgroundLayer(state: CurrencyIconState, blurR blurRadius = blurRadius, ) } + is CurrencyIconState.PaymentAccount -> Unit CurrencyIconState.Loading -> Unit CurrencyIconState.Locked -> Unit } @@ -193,7 +194,7 @@ private fun BoxScope.UrlBackground(imageUrl: String?, blurRadius: Dp) { AsyncImage( model = imageRequest, contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillBounds, modifier = Modifier .matchParentSize() .scale(SCALE_FACTOR) @@ -207,7 +208,7 @@ private fun BoxScope.ResBackground(res: Int, blurRadius: Dp) { Image( painter = painterResource(res), contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillBounds, modifier = Modifier .matchParentSize() .scale(SCALE_FACTOR) diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt index 052c994ac7..14e61f4061 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt @@ -19,7 +19,7 @@ import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.res.TangemTheme @Composable -internal fun RowScope.TokenRowPriceChangeContent( +fun RowScope.TokenRowPriceChangeContent( priceChangeState: PriceChangeState.Content, isFlickering: Boolean, isAvailable: Boolean = true, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt index b685eab6d8..7483bad306 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt @@ -1,8 +1,10 @@ package com.tangem.core.ui.ds.tabs import android.content.res.Configuration +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -86,7 +88,7 @@ fun TangemSegmentedPicker( val density = LocalDensity.current val itemsWidths = remember { mutableStateListOf(*Array(items.size) { 0.dp }) } - val selectedIndex = remember { mutableStateOf(items.indexOfFirstOrNull { it == initialSelectedItem } ?: 0) } + val selectedIndex = remember { mutableIntStateOf(items.indexOfFirstOrNull { it == initialSelectedItem } ?: 0) } val segmentHeight = remember { mutableStateOf(0.dp) } val shape = RoundedCornerShape(TangemTheme.dimens2.x25) @@ -105,7 +107,7 @@ fun TangemSegmentedPicker( ) { SegmentSelection( itemsWidths = itemsWidths, - selectedIndex = selectedIndex.value, + selectedIndex = selectedIndex.intValue, segmentHeight = segmentHeight.value, ) Row(verticalAlignment = Alignment.CenterVertically) { @@ -151,18 +153,30 @@ fun TangemSegmentedPicker( @Composable private fun SegmentSelection(itemsWidths: SnapshotStateList, selectedIndex: Int, segmentHeight: Dp) { + var hasInitiallyMeasured by remember { mutableStateOf(false) } + + val animationSpec: AnimationSpec = if (hasInitiallyMeasured) { + tween(durationMillis = 300) + } else { + snap() + } + val indicatorOffset by animateDpAsState( targetValue = itemsWidths.take(selectedIndex).fold(0.dp, Dp::plus), - animationSpec = tween(durationMillis = 300), + animationSpec = animationSpec, label = "indicatorOffset", ) val indicatorWidth by animateDpAsState( targetValue = itemsWidths[selectedIndex], - animationSpec = tween(durationMillis = 300), + animationSpec = animationSpec, label = "indicatorWidth", ) + LaunchedEffect(itemsWidths[selectedIndex] > 0.dp) { + if (itemsWidths[selectedIndex] > 0.dp) hasInitiallyMeasured = true + } + Box( modifier = Modifier .offset { diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt deleted file mode 100644 index 774e48671b..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ /dev/null @@ -1,304 +0,0 @@ -package com.tangem.core.ui.extensions - -import androidx.annotation.DrawableRes -import com.tangem.core.ui.R - -@Suppress("ComplexMethod", "LongMethod") -@DrawableRes -fun getActiveIconRes(blockchainId: String): Int { - return when (blockchainId) { - "ARBITRUM-ONE", "ARBITRUM/test" -> R.drawable.img_arbitrum_22 - "BTC", "BTC/test" -> R.drawable.img_btc_22 - "BCH" -> R.drawable.img_btc_cash_22 - "LTC" -> R.drawable.img_litecoin_22 - "ETH", "ETH/test" -> R.drawable.img_eth_22 - "ETC", "ETC/test" -> R.drawable.img_eth_classic_22 - "RSK" -> R.drawable.img_rsk_22 - "CARDANO", "CARDANO-S" -> R.drawable.img_cardano_22 - "XTZ" -> R.drawable.img_tezos_22 - "XRP" -> R.drawable.img_xrp_22 - "XLM", "XLM/test" -> R.drawable.img_stellar_22 - "AVALANCHE", "AVALANCHE/test" -> R.drawable.img_avalanche_22 - "POLYGON", "POLYGON/test" -> R.drawable.img_polygon_22 - "SOLANA", "SOLANA/test" -> R.drawable.img_solana_22 - "FTM", "FTM/test" -> R.drawable.img_fantom_22 - "BSC", "BSC/test", "BINANCE", "BINANCE/test" -> R.drawable.img_bsc_22 - "DOGE" -> R.drawable.img_dogecoin_22 - "TRON", "TRON/test" -> R.drawable.img_tron_22 - "GNO" -> R.drawable.img_gnosis_22 - "ETH-Pow", "ETH-Pow/test" -> R.drawable.img_eth_pow_22 - "ETH-Fair", "dischain" -> R.drawable.img_dischain_22 - "Polkadot", "Polkadot/test" -> R.drawable.img_polkadot_22 - "Kusama" -> R.drawable.img_kusama_22 - "OPTIMISM", "OPTIMISM/test" -> R.drawable.img_optimism_22 - "DASH" -> R.drawable.img_dash_22 - "KAS", "KAS/test" -> R.drawable.img_kaspa_22 - "The-Open-Network", "The-Open-Network/test" -> R.drawable.img_ton_22 - "KAVA", "KAVA/test" -> R.drawable.img_kava_22 - "ravencoin", "ravencoin/test" -> R.drawable.img_ravencoin_22 - "cosmos", "cosmos/test" -> R.drawable.img_cosmos_22 - "terra", "terra-luna" -> R.drawable.img_terra_22 - "terra-2", "terra-luna-2" -> R.drawable.img_terra2_22 - "cronos" -> R.drawable.img_cronos_22 - "TELOS", "TELOS/test" -> R.drawable.img_telos_22 - "aleph-zero", "aleph-zero/test" -> R.drawable.img_azero_22 - "octaspace", "octaspace/test" -> R.drawable.img_octaspace_22 - "chia", "chia/test" -> R.drawable.img_chia_22 - "NEAR", "NEAR/test" -> R.drawable.img_near_22 - "decimal", "decimal/test" -> R.drawable.img_decimal_22 - "xdc", "xdc/test" -> R.drawable.img_xdc_22 - "vechain", "vechain/test" -> R.drawable.img_vechain_22 - "aptos", "aptos/test" -> R.drawable.img_aptos_22 - "shibarium", "shibarium/test" -> R.drawable.img_shibarium_22 - "algorand", "algorand/test" -> R.drawable.img_algorand_22 - "hedera", "hedera/test" -> R.drawable.img_hedera_22 - "playa3ull" -> R.drawable.img_playa3ull_22 - "DUC" -> R.drawable.img_ducatus_22 - "aurora", "aurora/test" -> R.drawable.img_aurora_22 - "areon", "areon/test" -> R.drawable.img_areon_22 - "pls", "pls/test" -> R.drawable.img_pls_22 - "zkSyncEra", "zkSyncEra/test" -> R.drawable.img_zksync_22 - "moonbeam", "moonbeam/test" -> R.drawable.img_moonbeam_22 - "manta-pacific", "manta/test" -> R.drawable.img_manta_22 - "polygonZkEVM", "polygonZkEVM/test" -> R.drawable.img_polygon_22 - "moonriver", "moonriver/test" -> R.drawable.img_moonriver_22 - "mantle", "mantle/test" -> R.drawable.img_mantle_22 - "flare", "flare/test" -> R.drawable.img_flare_22 - "taraxa", "taraxa/test" -> R.drawable.img_taraxa_22 - "radiant" -> R.drawable.img_radiant_22 - "base" -> R.drawable.img_base_22 - "joystream" -> R.drawable.img_joystream_22 - "koinos", "koinos/test" -> R.drawable.img_koinos_22 - "bittensor" -> R.drawable.img_bittensor_22 - "blast", "blast/test" -> R.drawable.img_blast_22 - "filecoin" -> R.drawable.img_filecoin_22 - "cyber", "cyber/test" -> R.drawable.img_cyber_22 - "sei", "sei/test" -> R.drawable.img_sei_22 - "internet-computer" -> R.drawable.img_icp_22 - "sui", "sui/test" -> R.drawable.img_sui_22 - "energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22 - "energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22 - "core", "core/test" -> R.drawable.img_core_22 - "casper", "casper/test" -> R.drawable.img_casper_22 - "xodex" -> R.drawable.img_xodex_22 - "canxium" -> R.drawable.img_canxium_22 - "chiliz", "chiliz/test" -> R.drawable.img_chiliz_22 - "alephium", "alephium/test" -> R.drawable.img_alephium_22 - "clore-ai" -> R.drawable.img_clore_22 - "fact0rn" -> R.drawable.img_fact0rn_22 - "odyssey", "odyssey/test" -> R.drawable.img_odyssey_chain_22 - "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 - "sonic", "sonic/test" -> R.drawable.img_sonic_22 - "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.img_scroll_22 - "zklink", "zklink/test" -> R.drawable.img_zklink_22 - "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 - "pepecoin", "pepecoin/test" -> R.drawable.img_pepecoin_22 - "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 - "quai", "quai/test" -> R.drawable.img_quai_22 - "linea", "linea/test" -> R.drawable.img_linea_22 - "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 - "plasma", "plasma/test" -> R.drawable.img_plasma_22 - "monad", "monad/test" -> R.drawable.img_monad_22 - else -> R.drawable.ic_alert_24 - } -} - -@Suppress("ComplexMethod", "LongMethod") -@DrawableRes -fun getActiveIconResByCoinId(coinId: String): Int { - return when (coinId) { - "binancecoin" -> R.drawable.img_bsc_22 - "bitcoin" -> R.drawable.img_btc_22 - "bitcoin-cash" -> R.drawable.img_btc_cash_22 - "ethereum" -> R.drawable.img_eth_22 - "arbitrum-one" -> R.drawable.img_arbitrum_22 - "optimistic-ethereum" -> R.drawable.img_optimism_22 - "ethereum-classic" -> R.drawable.img_eth_classic_22 - "stellar" -> R.drawable.img_stellar_22 - "cardano" -> R.drawable.img_cardano_22 - "matic-network", "polygon-ecosystem-token" -> R.drawable.img_polygon_22 - "avalanche-2" -> R.drawable.img_avalanche_22 - "solana" -> R.drawable.img_solana_22 - "fantom" -> R.drawable.img_fantom_22 - "tron" -> R.drawable.img_tron_22 - "polkadot" -> R.drawable.img_polkadot_22 - "litecoin" -> R.drawable.img_litecoin_22 - "rootstock" -> R.drawable.img_rsk_22 - "tezos" -> R.drawable.img_tezos_22 - "ripple" -> R.drawable.img_xrp_22 - "dogecoin" -> R.drawable.img_dogecoin_22 - "xdai" -> R.drawable.img_gnosis_22 - "ethereum-pow-iou" -> R.drawable.img_eth_pow_22 - "ethereumfair", "dischain" -> R.drawable.img_dischain_22 - "kusama" -> R.drawable.img_kusama_22 - "dash" -> R.drawable.img_dash_22 - "kaspa", "kaspa/test" -> R.drawable.img_kaspa_22 - "ton" -> R.drawable.img_ton_22 - "kava" -> R.drawable.img_kava_22 - "ravencoin" -> R.drawable.img_ravencoin_22 - "terra" -> R.drawable.img_terra_22 - "terra-2" -> R.drawable.img_terra2_22 - "telos" -> R.drawable.img_telos_22 - "octaspace" -> R.drawable.img_octaspace_22 - "chia" -> R.drawable.img_chia_22 - "near" -> R.drawable.img_near_22 - "decimal" -> R.drawable.img_decimal_22 - "xdce-crowd-sale" -> R.drawable.img_xdc_22 - "vechain" -> R.drawable.img_vechain_22 - "aptos" -> R.drawable.img_aptos_22 - "shibarium" -> R.drawable.img_shibarium_22 - "algorand" -> R.drawable.img_algorand_22 - "hedera-hashgraph" -> R.drawable.img_hedera_22 - "playa3ull-games-2" -> R.drawable.img_playa3ull_22 - "ducatus" -> R.drawable.img_ducatus_22 - "aurora-near" -> R.drawable.img_aurora_22 - "areon" -> R.drawable.img_areon_22 - "pls" -> R.drawable.img_pls_22 - "zksync-ethereum" -> R.drawable.img_zksync_22 - "moonbeam" -> R.drawable.img_moonbeam_22 - "manta-pacific" -> R.drawable.img_manta_22 - "polygon-zkevm-ethereum" -> R.drawable.img_polygon_22 - "moonriver" -> R.drawable.img_moonriver_22 - "mantle" -> R.drawable.img_mantle_22 - "flare-networks" -> R.drawable.img_flare_22 - "taraxa" -> R.drawable.img_taraxa_22 - "radiant" -> R.drawable.img_radiant_22 - "base" -> R.drawable.img_base_22 - "joystream" -> R.drawable.img_joystream_22 - "koinos", "koinos/test" -> R.drawable.img_koinos_22 - "bittensor" -> R.drawable.img_bittensor_22 - "blast", "blast/test" -> R.drawable.img_blast_22 - "filecoin" -> R.drawable.img_filecoin_22 - "cyber", "cyber/test" -> R.drawable.img_cyber_22 - "sei", "sei/test" -> R.drawable.img_sei_22 - "internet-computer" -> R.drawable.img_icp_22 - "sui", "sui/test" -> R.drawable.img_sui_22 - "energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22 - "energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22 - "core", "core/test" -> R.drawable.img_core_22 - "casper-network" -> R.drawable.img_casper_22 - "xodex" -> R.drawable.img_xodex_22 - "canxium" -> R.drawable.img_canxium_22 - "chiliz", "chiliz/test" -> R.drawable.img_chiliz_22 - "alephium", "alephium/test" -> R.drawable.img_alephium_22 - "clore-ai" -> R.drawable.img_clore_22 - "fact0rn" -> R.drawable.img_fact0rn_22 - "odyssey", "odyssey/test" -> R.drawable.img_odyssey_chain_22 - "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 - "sonic", "sonic/test" -> R.drawable.img_sonic_22 - "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.img_scroll_22 - "zklink", "zklink/test" -> R.drawable.img_zklink_22 - "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 - "pepecoin-network", "pepecoin-network/test" -> R.drawable.img_pepecoin_22 - "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 - "quai", "quai/test" -> R.drawable.img_quai_22 - "linea", "linea/test" -> R.drawable.img_linea_22 - "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 - "plasma", "plasma/test" -> R.drawable.img_plasma_22 - "monad", "monad/test" -> R.drawable.img_monad_22 - else -> R.drawable.ic_alert_24 - } -} - -@Suppress("ComplexMethod", "LongMethod") -@DrawableRes -fun getGreyedOutIconRes(blockchainId: String): Int { - return when (blockchainId) { - "ARBITRUM-ONE", "ARBITRUM/test" -> R.drawable.ic_arbitrum_22 - "BTC", "BTC/test" -> R.drawable.ic_bitcoin_16 - "BCH" -> R.drawable.ic_bitcoin_cash_16 - "LTC" -> R.drawable.ic_litecoin_22 - "ETH", "ETH/test" -> R.drawable.ic_eth_16 - "ETC", "ETC/test" -> R.drawable.ic_eth_16 - "RSK" -> R.drawable.ic_rsk_16 - "CARDANO", "CARDANO-S" -> R.drawable.ic_cardano_16 - "XTZ" -> R.drawable.ic_tezos_16 - "XRP" -> R.drawable.ic_xrp_22 - "XLM", "XLM/test" -> R.drawable.ic_stellar_16 - "AVALANCHE", "AVALANCHE/test" -> R.drawable.ic_avalanche_22 - "POLYGON", "POLYGON/test" -> R.drawable.ic_polygon_22 - "SOLANA", "SOLANA/test" -> R.drawable.ic_solana_16 - "FTM", "FTM/test" -> R.drawable.ic_fantom_22 - "BSC", "BSC/test", "BINANCE", "BINANCE/test" -> R.drawable.ic_bsc_16 - "DOGE" -> R.drawable.ic_dogecoin_16 - "TRON", "TRON/test" -> R.drawable.ic_tron_22 - "GNO" -> R.drawable.ic_gnosis_22 - "ETH-Pow", "ETH-Pow/test" -> R.drawable.ic_ethereumpow_22 - "ETH-Fair", "dischain" -> R.drawable.ic_dischain_22 - "Polkadot", "Polkadot/test" -> R.drawable.ic_polkadot_16 - "Kusama" -> R.drawable.ic_kusama_16 - "OPTIMISM", "OPTIMISM/test" -> R.drawable.ic_optimism_22 - "DASH" -> R.drawable.ic_dash_22 - "KAS", "KAS/test" -> R.drawable.ic_kaspa_22 - "The-Open-Network", "The-Open-Network/test" -> R.drawable.ic_ton_22 - "KAVA", "KAVA/test" -> R.drawable.ic_kava_22 - "ravencoin", "ravencoin/test" -> R.drawable.ic_ravencoin_22 - "cosmos", "cosmos/test" -> R.drawable.ic_cosmos_22 - "terra", "terra-luna" -> R.drawable.ic_terra_22 - "terra-2", "terra-luna-2" -> R.drawable.ic_terra2_22 - "cronos" -> R.drawable.ic_cronos_22 - "TELOS", "TELOS/test" -> R.drawable.ic_telos_22 - "aleph-zero", "aleph-zero/test" -> R.drawable.ic_azero_22 - "octaspace", "octaspace/test" -> R.drawable.ic_octaspace_22 - "chia", "chia/test" -> R.drawable.ic_chia_22 - "NEAR", "NEAR/test" -> R.drawable.ic_near_22 - "decimal", "decimal/test" -> R.drawable.ic_decimal_22 - "xdc", "xdc/test" -> R.drawable.ic_xdc_22 - "vechain", "vechain/test" -> R.drawable.ic_vechain_22 - "aptos", "aptos/test" -> R.drawable.ic_aptos_22 - "shibarium", "shibarium/test" -> R.drawable.ic_shibarium_22 - "algorand", "algorand/test" -> R.drawable.ic_algorand_22 - "hedera", "hedera/test" -> R.drawable.ic_hedera_22 - "playa3ull" -> R.drawable.ic_playa3ull_22 - "DUC" -> R.drawable.ic_ducatus_22 - "aurora", "aurora/test" -> R.drawable.ic_aurora_22 - "areon", "areon/test" -> R.drawable.ic_areon_22 - "pls", "pls/test" -> R.drawable.ic_pls_22 - "zkSyncEra", "zkSyncEra/test" -> R.drawable.ic_zksync_22 - "moonbeam", "moonbeam/test" -> R.drawable.ic_moonbeam_22 - "manta-pacific", "manta/test" -> R.drawable.ic_manta_22 - "polygonZkEVM", "polygonZkEVM/test" -> R.drawable.ic_polygon_22 - "moonriver", "moonriver/test" -> R.drawable.ic_moonriver_22 - "mantle", "mantle/test" -> R.drawable.ic_mantle_22 - "flare", "flare/test" -> R.drawable.ic_flare_22 - "taraxa", "taraxa/test" -> R.drawable.ic_taraxa_22 - "radiant" -> R.drawable.ic_radiant_22 - "base", "base/test" -> R.drawable.ic_base_22 - "joystream" -> R.drawable.ic_joystream_22 - "koinos", "koinos/test" -> R.drawable.ic_koinos_22 - "bittensor" -> R.drawable.ic_bittensor_22 - "blast", "blast/test" -> R.drawable.ic_blast_22 - "filecoin" -> R.drawable.ic_filecoin_22 - "cyber", "cyber/test" -> R.drawable.ic_cyber_22 - "sei", "sei/test" -> R.drawable.ic_sei_22 - "internet-computer" -> R.drawable.ic_icp_22 - "sui", "sui/test" -> R.drawable.ic_sui_22 - "energy-web-chain", "energy-web-chain/test" -> R.drawable.ic_energy_web_22 - "energy-web-x", "energy-web-x/test" -> R.drawable.ic_energy_web_22 - "core", "core/test" -> R.drawable.ic_core_22 - "casper", "casper/test" -> R.drawable.ic_casper_22 - "xodex" -> R.drawable.ic_xodex_22 - "canxium" -> R.drawable.ic_canxium_22 - "chiliz", "chiliz/test" -> R.drawable.ic_chiliz_22 - "alephium", "alephium/test" -> R.drawable.ic_alephium_22 - "clore-ai" -> R.drawable.ic_clore_22 - "fact0rn" -> R.drawable.ic_fact0rn_22 - "odyssey", "odyssey/test" -> R.drawable.ic_odyssey_chain_22 - "bitrock", "bitrock/test" -> R.drawable.ic_bitrock_22 - "sonic", "sonic/test" -> R.drawable.ic_sonic_22 - "apechain", "apechain/test" -> R.drawable.ic_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_scroll_22 - "zklink", "zklink/test" -> R.drawable.ic_zklink_22 - "vanar-chain", "vanar-chain/test" -> R.drawable.ic_vanar_22 - "pepecoin", "pepecoin/test" -> R.drawable.ic_pepecoin_22 - "hyperliquid", "hyperliquid/test" -> R.drawable.ic_hyperliquid_22 - "quai", "quai/test" -> R.drawable.ic_quai_22 - "linea", "linea/test" -> R.drawable.ic_linea_22 - "arbitrum-nova" -> R.drawable.ic_arbitrum_nova_22 - "plasma", "plasma/test" -> R.drawable.ic_plasma_22 - "monad", "monad/test" -> R.drawable.ic_monad_22 - else -> R.drawable.ic_alert_24 - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt index 6c8634158c..2065184093 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt @@ -1,34 +1,15 @@ package com.tangem.core.ui.extensions -import androidx.annotation.DrawableRes import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.core.graphics.toColorInt import com.tangem.core.ui.res.TangemColorPalette import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network private const val LIGHT_LUMINANCE = 0.5f private const val COLOR_HEX_START_INDEX = 2 private const val COLOR_HEX_END_INDEX = 7 -/** - * Retrieves the resource ID for the network of a [CryptoCurrency]. - * - * @return Drawable resource ID for the network. - */ -@get:DrawableRes -val CryptoCurrency.networkIconResId: Int - get() = network.iconResId - -/** - * Retrieves the resource ID. - * - * @return Drawable resource ID for the network. - */ -val Network.iconResId: Int - get() = getActiveIconRes(rawId) - /** * Tries to extract a background color from the contract address of a token. * diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index cb9dc458ca..f61cdd1722 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -29,6 +29,10 @@ import com.tangem.core.ui.haptic.DefaultHapticManager import com.tangem.core.ui.haptic.HapticManager import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler +import com.tangem.core.ui.res.generated.TangemColors3 +import com.tangem.core.ui.res.generated.TangemDimens3 +import com.tangem.core.ui.res.generated.TangemTypography3 +import com.tangem.core.ui.res.generated.lightColors3 import com.tangem.core.ui.windowsize.WindowSize import com.tangem.core.ui.windowsize.rememberWindowSize import com.tangem.domain.apptheme.model.AppThemeMode @@ -161,11 +165,17 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemColors.current + @Deprecated("Use colors3 instead", ReplaceWith("TangemTheme.colors3")) val colors2: TangemColors2 @Composable @ReadOnlyComposable get() = LocalTangemColors2.current + val colors3: TangemColors3 + @Composable + @ReadOnlyComposable + get() = LocalTangemColors3.current + val typography: TangemTypography @Composable @ReadOnlyComposable @@ -176,6 +186,11 @@ object TangemTheme { @ReadOnlyComposable get() = TangemTypography2(InterFamily) + val typography3: TangemTypography3 + @Composable + @ReadOnlyComposable + get() = LocalTangemTypography3.current + val dimens: TangemDimens @Composable @ReadOnlyComposable @@ -186,6 +201,11 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemDimens2.current + val dimens3: TangemDimens3 + @Composable + @ReadOnlyComposable + get() = LocalTangemDimens3.current + val shapes: TangemShapes @Composable @ReadOnlyComposable @@ -366,6 +386,10 @@ internal val LocalTangemColors2 = staticCompositionLocalOf { error("No TangemColors2 provided") } +internal val LocalTangemColors3 = staticCompositionLocalOf { + lightColors3() +} + internal val LocalTangemTypography = staticCompositionLocalOf { TangemTypography(RobotoFamily) } @@ -382,6 +406,14 @@ private val LocalTangemDimens2 = staticCompositionLocalOf { TangemDimens2() } +internal val LocalTangemDimens3 = staticCompositionLocalOf { + TangemDimens3() +} + +internal val LocalTangemTypography3 = staticCompositionLocalOf { + TangemTypography3(InterFamily) +} + private val LocalTangemShapes = staticCompositionLocalOf { error("No TangemShapes provided") } diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index 8428d0f240..9fcfadf1f8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -10,6 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import com.tangem.core.ui.components.haze.ProvideHaze import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.res.generated.TangemDimens3 +import com.tangem.core.ui.res.generated.TangemTypography3 +import com.tangem.core.ui.res.generated.darkColors3 +import com.tangem.core.ui.res.generated.lightColors3 /** * Provides additional theming for redesigned components. @@ -21,17 +25,30 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { val themeColors = if (LocalIsInDarkTheme.current) darkThemeColors() else lightThemeColors(redesign = true) val rememberedColors = remember { themeColors } .apply { update(themeColors) } + + val themeColors3 = if (LocalIsInDarkTheme.current) darkColors3() else lightColors3() + val rememberedColors3 = remember { themeColors3 } + .apply { update(themeColors3) } + val rootBackgroundColor = rememberedColors.background.secondary + val tangemDimens3 = remember { TangemDimens3() } + val tangemTypography3 = remember { TangemTypography3(InterFamily) } + val tangemTypography2 = remember { TangemTypography2(InterFamily) } + val tangemTypography = remember { TangemTypography(InterFamily) } + MaterialTheme( - colorScheme = tangemColorScheme(colors = themeColors), + colorScheme = tangemColorScheme(colors = rememberedColors), ) { CompositionLocalProvider( LocalRedesignEnabled provides true, - LocalTangemColors provides themeColors, + LocalTangemColors provides rememberedColors, LocalTangemColors2 provides if (LocalIsInDarkTheme.current) darkThemeColors2() else lightThemeColors2(), - LocalTangemTypography2 provides TangemTypography2(InterFamily), - LocalTangemTypography provides TangemTypography(InterFamily), + LocalTangemColors3 provides rememberedColors3, + LocalTangemDimens3 provides tangemDimens3, + LocalTangemTypography3 provides tangemTypography3, + LocalTangemTypography2 provides tangemTypography2, + LocalTangemTypography provides tangemTypography, LocalRootBackgroundColor provides remember(rootBackgroundColor) { mutableStateOf(rootBackgroundColor) }, ) { CompositionLocalProvider( diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash new file mode 100644 index 0000000000..ff5efb6203 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash @@ -0,0 +1 @@ +84387e888f54e5056380c38e077962bdfa4a32cfca194d822c13aa7e35661968 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColorPalette.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColorPalette.kt new file mode 100644 index 0000000000..2a72bad4db --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColorPalette.kt @@ -0,0 +1,149 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +internal object TangemColorPalette { + object Base { + val black = Color(0xFF000000) + val white = Color(0xFFFFFFFF) + } + + object Neutral { + val `5` = Color(0xFFF4F4F4) + val `10` = Color(0xFFEAEAEA) + val `20` = Color(0xFFD0D0D0) + val `30` = Color(0xFFB5B5B5) + val `40` = Color(0xFF989898) + val `50` = Color(0xFF838383) + val `60` = Color(0xFF6F6F6F) + val `70` = Color(0xFF4A4A4A) + val `80` = Color(0xFF2C2C2C) + val `90` = Color(0xFF1B1B1B) + val `95` = Color(0xFF0F0F0F) + } + + object Blue { + val `5` = Color(0xFFE9F7FF) + val `10` = Color(0xFFDBF1FF) + val `20` = Color(0xFF98D7FF) + val `30` = Color(0xFF5EBDF9) + val `40` = Color(0xFF109FF0) + val `50` = Color(0xFF0090F9) + val `60` = Color(0xFF0077E1) + val `70` = Color(0xFF0C58AF) + val `80` = Color(0xFF143C70) + val `90` = Color(0xFF1B304A) + val `95` = Color(0xFF101C2C) + } + + object Violet { + val `5` = Color(0xFFF6F1FF) + val `10` = Color(0xFFEEE7FD) + val `20` = Color(0xFFDAC8FB) + val `30` = Color(0xFFC5A5FC) + val `40` = Color(0xFFB07BFD) + val `50` = Color(0xFFA967FD) + val `60` = Color(0xFF9258DC) + val `70` = Color(0xFF67419B) + val `80` = Color(0xFF473068) + val `90` = Color(0xFF332846) + val `95` = Color(0xFF201B2A) + } + + object Red { + val `5` = Color(0xFFFFF0F7) + val `10` = Color(0xFFFFE8EC) + val `20` = Color(0xFFFFC0C3) + val `30` = Color(0xFFFF979D) + val `40` = Color(0xFFFF5E66) + val `50` = Color(0xFFFE4142) + val `60` = Color(0xFFE12C2E) + val `70` = Color(0xFF9E2729) + val `80` = Color(0xFF6D2323) + val `90` = Color(0xFF4C2121) + val `95` = Color(0xFF2B1818) + } + + object Orange { + val `5` = Color(0xFFFFF0EA) + val `10` = Color(0xFFFFE5DB) + val `20` = Color(0xFFFFC3AD) + val `30` = Color(0xFFFF9976) + val `40` = Color(0xFFFA6931) + val `50` = Color(0xFFF25508) + val `60` = Color(0xFFD3480E) + val `70` = Color(0xFF953715) + val `80` = Color(0xFF652A18) + val `90` = Color(0xFF43251A) + val `95` = Color(0xFF2A1A13) + } + + object Green { + val `5` = Color(0xFFEBF6ED) + val `10` = Color(0xFFDCFBE3) + val `20` = Color(0xFF9EE1AB) + val `30` = Color(0xFF64C973) + val `40` = Color(0xFF2DAE3B) + val `50` = Color(0xFF2DA30D) + val `60` = Color(0xFF208900) + val `70` = Color(0xFF1E6110) + val `80` = Color(0xFF1C4415) + val `90` = Color(0xFF1D3319) + val `95` = Color(0xFF162114) + } + + object Yellow { + val `5` = Color(0xFFFAF3E5) + val `10` = Color(0xFFFEF5E5) + val `20` = Color(0xFFF7CA75) + val `30` = Color(0xFFF4B42F) + val `40` = Color(0xFFEFA210) + val `50` = Color(0xFFE68A03) + val `60` = Color(0xFFCD7A11) + val `70` = Color(0xFF965001) + val `80` = Color(0xFF573414) + val `90` = Color(0xFF3D2918) + val `95` = Color(0xFF241B13) + } + + object Opaque { + object BaseBlack { + val `5` = Color(0x0D000000) + val `10` = Color(0x1A000000) + val `15` = Color(0x26000000) + val `20` = Color(0x33000000) + val `25` = Color(0x40000000) + val `30` = Color(0x4D000000) + val `40` = Color(0x66000000) + val `50` = Color(0x80000000) + val `60` = Color(0x99000000) + val `80` = Color(0xCC000000) + } + + object BaseWhite { + val `5` = Color(0x0DFFFFFF) + val `10` = Color(0x1AFFFFFF) + val `15` = Color(0x26FFFFFF) + val `20` = Color(0x33FFFFFF) + val `25` = Color(0x40FFFFFF) + val `30` = Color(0x4DFFFFFF) + val `40` = Color(0x66FFFFFF) + val `50` = Color(0x80FFFFFF) + val `60` = Color(0x99FFFFFF) + val `80` = Color(0xCCFFFFFF) + } + + object Neutral95 { + val `5` = Color(0x0D0F0F0F) + val `10` = Color(0x1A0F0F0F) + val `15` = Color(0x260F0F0F) + val `40` = Color(0x660F0F0F) + val `60` = Color(0x990F0F0F) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt new file mode 100644 index 0000000000..12e2412528 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt @@ -0,0 +1,714 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +@Stable +class TangemColors3 internal constructor( + val text: Text, + val bg: Bg, + val icon: Icon, + val border: Border, + val overlay: Overlay, + val interaction: Interaction, + val material: Material, +) { + + @Stable + class Text internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + val staticLight: StaticLight, + val staticDark: StaticDark, + val inverse: Inverse, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + + @Stable + class StaticLight internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: StaticLight) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class StaticDark internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: StaticDark) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class Inverse internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: Inverse) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class Status internal constructor( + success: Color, + error: Color, + warning: Color, + info: Color, + ) { + var success by mutableStateOf(success) + private set + var error by mutableStateOf(error) + private set + var warning by mutableStateOf(warning) + private set + var info by mutableStateOf(info) + private set + + fun update(other: Status) { + success = other.success + error = other.error + warning = other.warning + info = other.info + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Text) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + staticLight.update(other.staticLight) + staticDark.update(other.staticDark) + inverse.update(other.inverse) + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Bg internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + inverse: Color, + base: Color, + disabled: Color, + val opaque: Opaque, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + var inverse by mutableStateOf(inverse) + private set + var base by mutableStateOf(base) + private set + var disabled by mutableStateOf(disabled) + private set + + @Stable + class Opaque internal constructor( + primary: Color, + secondary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + + fun update(other: Opaque) { + primary = other.primary + secondary = other.secondary + } + } + + @Stable + class Status internal constructor( + success: Color, + successSubtle: Color, + error: Color, + errorSubtle: Color, + warning: Color, + warningSubtle: Color, + info: Color, + infoSubtle: Color, + ) { + var success by mutableStateOf(success) + private set + var successSubtle by mutableStateOf(successSubtle) + private set + var error by mutableStateOf(error) + private set + var errorSubtle by mutableStateOf(errorSubtle) + private set + var warning by mutableStateOf(warning) + private set + var warningSubtle by mutableStateOf(warningSubtle) + private set + var info by mutableStateOf(info) + private set + var infoSubtle by mutableStateOf(infoSubtle) + private set + + fun update(other: Status) { + success = other.success + successSubtle = other.successSubtle + error = other.error + errorSubtle = other.errorSubtle + warning = other.warning + warningSubtle = other.warningSubtle + info = other.info + infoSubtle = other.infoSubtle + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Bg) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + inverse = other.inverse + base = other.base + disabled = other.disabled + opaque.update(other.opaque) + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Icon internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + staticLight: Color, + staticDark: Color, + inverse: Color, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + var staticLight by mutableStateOf(staticLight) + private set + var staticDark by mutableStateOf(staticDark) + private set + var inverse by mutableStateOf(inverse) + private set + + @Stable + class Status internal constructor( + success: Color, + error: Color, + warning: Color, + info: Color, + ) { + var success by mutableStateOf(success) + private set + var error by mutableStateOf(error) + private set + var warning by mutableStateOf(warning) + private set + var info by mutableStateOf(info) + private set + + fun update(other: Status) { + success = other.success + error = other.error + warning = other.warning + info = other.info + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Icon) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + staticLight = other.staticLight + staticDark = other.staticDark + inverse = other.inverse + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Border internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + val inverse: Inverse, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + + @Stable + class Inverse internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: Inverse) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class Status internal constructor( + success: Color, + successSubtle: Color, + error: Color, + errorSubtle: Color, + warning: Color, + warningSubtle: Color, + info: Color, + infoSubtle: Color, + ) { + var success by mutableStateOf(success) + private set + var successSubtle by mutableStateOf(successSubtle) + private set + var error by mutableStateOf(error) + private set + var errorSubtle by mutableStateOf(errorSubtle) + private set + var warning by mutableStateOf(warning) + private set + var warningSubtle by mutableStateOf(warningSubtle) + private set + var info by mutableStateOf(info) + private set + var infoSubtle by mutableStateOf(infoSubtle) + private set + + fun update(other: Status) { + success = other.success + successSubtle = other.successSubtle + error = other.error + errorSubtle = other.errorSubtle + warning = other.warning + warningSubtle = other.warningSubtle + info = other.info + infoSubtle = other.infoSubtle + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Border) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + inverse.update(other.inverse) + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Overlay internal constructor( + modal: Color, + ) { + var modal by mutableStateOf(modal) + private set + + fun update(other: Overlay) { + modal = other.modal + } + } + + @Stable + class Interaction internal constructor( + pressStaticLight: Color, + pressStaticDark: Color, + val press: Press, + val focusRing: FocusRing, + ) { + var pressStaticLight by mutableStateOf(pressStaticLight) + private set + var pressStaticDark by mutableStateOf(pressStaticDark) + private set + + @Stable + class Press internal constructor( + default: Color, + inverse: Color, + ) { + var default by mutableStateOf(default) + private set + var inverse by mutableStateOf(inverse) + private set + + fun update(other: Press) { + default = other.default + inverse = other.inverse + } + } + + @Stable + class FocusRing internal constructor( + default: Color, + brand: Color, + ) { + var default by mutableStateOf(default) + private set + var brand by mutableStateOf(brand) + private set + + fun update(other: FocusRing) { + default = other.default + brand = other.brand + } + } + + fun update(other: Interaction) { + pressStaticLight = other.pressStaticLight + pressStaticDark = other.pressStaticDark + press.update(other.press) + focusRing.update(other.focusRing) + } + } + + @Stable + class Material internal constructor( + val tint: Tint, + val fill: Fill, + val lighten: Lighten, + val softLight: SoftLight, + val border: Border, + ) { + + @Stable + class Tint internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: Tint) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class Fill internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: Fill) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class Lighten internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: Lighten) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class SoftLight internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: SoftLight) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class Border internal constructor( + start: Color, + mid: Color, + end: Color, + ) { + var start by mutableStateOf(start) + private set + var mid by mutableStateOf(mid) + private set + var end by mutableStateOf(end) + private set + + fun update(other: Border) { + start = other.start + mid = other.mid + end = other.end + } + } + + fun update(other: Material) { + tint.update(other.tint) + fill.update(other.fill) + lighten.update(other.lighten) + softLight.update(other.softLight) + border.update(other.border) + } + } + + fun update(other: TangemColors3) { + text.update(other.text) + bg.update(other.bg) + icon.update(other.icon) + border.update(other.border) + overlay.update(other.overlay) + interaction.update(other.interaction) + material.update(other.material) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt new file mode 100644 index 0000000000..a2fa0d535e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt @@ -0,0 +1,173 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + * Theme: Dark + */ +internal fun darkColors3() = + TangemColors3( + text = TangemColors3.Text( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColors3.Text.StaticLight( + primary = TangemColorPalette.Base.black, + secondary = TangemColorPalette.Opaque.BaseBlack.`60`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`40`, + ), + staticDark = TangemColors3.Text.StaticDark( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + ), + inverse = TangemColors3.Text.Inverse( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Opaque.Neutral95.`60`, + tertiary = TangemColorPalette.Opaque.Neutral95.`40`, + ), + status = TangemColors3.Text.Status( + success = TangemColorPalette.Green.`40`, + error = TangemColorPalette.Red.`40`, + warning = TangemColorPalette.Yellow.`40`, + info = TangemColorPalette.Blue.`40`, + ), + accent = TangemColors3.Text.Accent( + blue = TangemColorPalette.Blue.`40`, + violet = TangemColorPalette.Violet.`40`, + red = TangemColorPalette.Red.`40`, + orange = TangemColorPalette.Orange.`40`, + yellow = TangemColorPalette.Yellow.`40`, + green = TangemColorPalette.Green.`40`, + ), + ), + bg = TangemColors3.Bg( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Neutral.`90`, + tertiary = TangemColorPalette.Neutral.`80`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColorPalette.Neutral.`5`, + base = TangemColorPalette.Base.black, + disabled = TangemColorPalette.Neutral.`70`, + opaque = TangemColors3.Bg.Opaque( + primary = TangemColorPalette.Opaque.BaseWhite.`5`, + secondary = TangemColorPalette.Opaque.BaseWhite.`10`, + ), + status = TangemColors3.Bg.Status( + success = TangemColorPalette.Green.`50`, + successSubtle = TangemColorPalette.Green.`90`, + error = TangemColorPalette.Red.`50`, + errorSubtle = TangemColorPalette.Red.`90`, + warning = TangemColorPalette.Yellow.`50`, + warningSubtle = TangemColorPalette.Yellow.`90`, + info = TangemColorPalette.Blue.`50`, + infoSubtle = TangemColorPalette.Blue.`90`, + ), + accent = TangemColors3.Bg.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + icon = TangemColors3.Icon( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColorPalette.Base.black, + staticDark = TangemColorPalette.Base.white, + inverse = TangemColorPalette.Neutral.`95`, + status = TangemColors3.Icon.Status( + success = TangemColorPalette.Green.`40`, + error = TangemColorPalette.Red.`40`, + warning = TangemColorPalette.Yellow.`40`, + info = TangemColorPalette.Blue.`40`, + ), + accent = TangemColors3.Icon.Accent( + blue = TangemColorPalette.Blue.`40`, + violet = TangemColorPalette.Violet.`40`, + red = TangemColorPalette.Red.`40`, + orange = TangemColorPalette.Orange.`40`, + yellow = TangemColorPalette.Yellow.`40`, + green = TangemColorPalette.Green.`40`, + ), + ), + border = TangemColors3.Border( + primary = TangemColorPalette.Opaque.BaseWhite.`5`, + secondary = TangemColorPalette.Opaque.BaseWhite.`10`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`20`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColors3.Border.Inverse( + primary = TangemColorPalette.Opaque.BaseBlack.`5`, + secondary = TangemColorPalette.Opaque.BaseBlack.`10`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`20`, + ), + status = TangemColors3.Border.Status( + success = TangemColorPalette.Green.`40`, + successSubtle = TangemColorPalette.Green.`80`, + error = TangemColorPalette.Red.`40`, + errorSubtle = TangemColorPalette.Red.`80`, + warning = TangemColorPalette.Yellow.`40`, + warningSubtle = TangemColorPalette.Yellow.`80`, + info = TangemColorPalette.Blue.`40`, + infoSubtle = TangemColorPalette.Blue.`80`, + ), + accent = TangemColors3.Border.Accent( + blue = TangemColorPalette.Blue.`40`, + violet = TangemColorPalette.Violet.`40`, + red = TangemColorPalette.Red.`40`, + orange = TangemColorPalette.Orange.`40`, + yellow = TangemColorPalette.Yellow.`40`, + green = TangemColorPalette.Green.`40`, + ), + ), + overlay = TangemColors3.Overlay( + modal = TangemColorPalette.Opaque.BaseBlack.`80`, + ), + interaction = TangemColors3.Interaction( + pressStaticLight = TangemColorPalette.Opaque.BaseBlack.`10`, + pressStaticDark = TangemColorPalette.Opaque.BaseWhite.`10`, + press = TangemColors3.Interaction.Press( + default = TangemColorPalette.Opaque.BaseWhite.`10`, + inverse = TangemColorPalette.Opaque.BaseBlack.`10`, + ), + focusRing = TangemColors3.Interaction.FocusRing( + default = TangemColorPalette.Neutral.`5`, + brand = TangemColorPalette.Blue.`50`, + ), + ), + material = TangemColors3.Material( + tint = TangemColors3.Material.Tint( + glass = Color(0x662C2C2C), + blur = Color(0x00000000), + solid = Color(0x1AFFFFFF), + ), + fill = TangemColors3.Material.Fill( + glass = Color(0x00000000), + blur = Color(0x1AFFFFFF), + solid = Color(0xE62C2C2C), + ), + lighten = TangemColors3.Material.Lighten( + glass = Color(0x33181818), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + softLight = TangemColors3.Material.SoftLight( + glass = Color(0x1A000000), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + border = TangemColors3.Material.Border( + start = Color(0x33FFFFFF), + mid = Color(0x00FFFFFF), + end = Color(0x1AFFFFFF), + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt new file mode 100644 index 0000000000..05c34c6f5e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt @@ -0,0 +1,173 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + * Theme: Light + */ +internal fun lightColors3() = + TangemColors3( + text = TangemColors3.Text( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Opaque.Neutral95.`60`, + tertiary = TangemColorPalette.Opaque.Neutral95.`40`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColors3.Text.StaticLight( + primary = TangemColorPalette.Base.black, + secondary = TangemColorPalette.Opaque.BaseBlack.`60`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`40`, + ), + staticDark = TangemColors3.Text.StaticDark( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + ), + inverse = TangemColors3.Text.Inverse( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + ), + status = TangemColors3.Text.Status( + success = TangemColorPalette.Green.`50`, + error = TangemColorPalette.Red.`50`, + warning = TangemColorPalette.Yellow.`50`, + info = TangemColorPalette.Blue.`50`, + ), + accent = TangemColors3.Text.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + bg = TangemColors3.Bg( + primary = TangemColorPalette.Neutral.`5`, + secondary = TangemColorPalette.Base.white, + tertiary = TangemColorPalette.Neutral.`10`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColorPalette.Neutral.`95`, + base = TangemColorPalette.Neutral.`5`, + disabled = TangemColorPalette.Neutral.`20`, + opaque = TangemColors3.Bg.Opaque( + primary = TangemColorPalette.Opaque.Neutral95.`5`, + secondary = TangemColorPalette.Opaque.Neutral95.`10`, + ), + status = TangemColors3.Bg.Status( + success = TangemColorPalette.Green.`50`, + successSubtle = TangemColorPalette.Green.`10`, + error = TangemColorPalette.Red.`50`, + errorSubtle = TangemColorPalette.Red.`10`, + warning = TangemColorPalette.Yellow.`50`, + warningSubtle = TangemColorPalette.Yellow.`10`, + info = TangemColorPalette.Blue.`50`, + infoSubtle = TangemColorPalette.Blue.`10`, + ), + accent = TangemColors3.Bg.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + icon = TangemColors3.Icon( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Opaque.Neutral95.`60`, + tertiary = TangemColorPalette.Opaque.Neutral95.`40`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColorPalette.Base.black, + staticDark = TangemColorPalette.Base.white, + inverse = TangemColorPalette.Base.white, + status = TangemColors3.Icon.Status( + success = TangemColorPalette.Green.`50`, + error = TangemColorPalette.Red.`50`, + warning = TangemColorPalette.Yellow.`50`, + info = TangemColorPalette.Blue.`50`, + ), + accent = TangemColors3.Icon.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + border = TangemColors3.Border( + primary = TangemColorPalette.Opaque.BaseBlack.`5`, + secondary = TangemColorPalette.Opaque.BaseBlack.`10`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`20`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColors3.Border.Inverse( + primary = TangemColorPalette.Opaque.BaseWhite.`5`, + secondary = TangemColorPalette.Opaque.BaseWhite.`10`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`20`, + ), + status = TangemColors3.Border.Status( + success = TangemColorPalette.Green.`50`, + successSubtle = TangemColorPalette.Green.`20`, + error = TangemColorPalette.Red.`50`, + errorSubtle = TangemColorPalette.Red.`20`, + warning = TangemColorPalette.Yellow.`50`, + warningSubtle = TangemColorPalette.Yellow.`20`, + info = TangemColorPalette.Blue.`50`, + infoSubtle = TangemColorPalette.Blue.`20`, + ), + accent = TangemColors3.Border.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + overlay = TangemColors3.Overlay( + modal = TangemColorPalette.Opaque.BaseBlack.`60`, + ), + interaction = TangemColors3.Interaction( + pressStaticLight = TangemColorPalette.Opaque.BaseBlack.`10`, + pressStaticDark = TangemColorPalette.Opaque.BaseWhite.`10`, + press = TangemColors3.Interaction.Press( + default = TangemColorPalette.Opaque.BaseBlack.`10`, + inverse = TangemColorPalette.Opaque.BaseWhite.`10`, + ), + focusRing = TangemColors3.Interaction.FocusRing( + default = TangemColorPalette.Neutral.`95`, + brand = TangemColorPalette.Blue.`50`, + ), + ), + material = TangemColors3.Material( + tint = TangemColors3.Material.Tint( + glass = Color(0x00000000), + blur = Color(0x00000000), + solid = Color(0x1AFFFFFF), + ), + fill = TangemColors3.Material.Fill( + glass = Color(0x00000000), + blur = Color(0x99FFFFFF), + solid = Color(0xE6FFFFFF), + ), + lighten = TangemColors3.Material.Lighten( + glass = Color(0x80F7F7F7), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + softLight = TangemColors3.Material.SoftLight( + glass = Color(0x33000000), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + border = TangemColors3.Material.Border( + start = Color(0x26000000), + mid = Color(0x00000000), + end = Color(0x1A000000), + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimens3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimens3.kt new file mode 100644 index 0000000000..27f3b6c69e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimens3.kt @@ -0,0 +1,108 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +@Stable +class TangemDimens3 internal constructor( + val opacity: Opacity = Opacity(), + val blur: Blur = Blur(), + val borderRadius: BorderRadius = BorderRadius(), + val borderWidth: BorderWidth = BorderWidth(), + val size: Size = Size(), + val spacing: Spacing = Spacing(), +) { + @Stable + class Opacity internal constructor( + val disabled: Float = 0.4f, + ) + + @Stable + class Blur internal constructor( + val card: Dp = 48.dp, + val Button: Dp = 32.dp, + ) + + @Stable + class BorderRadius internal constructor( + val b100: Dp = 8.dp, + val b150: Dp = 12.dp, + val b200: Dp = 16.dp, + val b250: Dp = 20.dp, + val b300: Dp = 24.dp, + val b400: Dp = 32.dp, + val none: Dp = 0.dp, + val b075: Dp = 6.dp, + val b050: Dp = 4.dp, + val full: Dp = 999.dp, + ) + + @Stable + class BorderWidth internal constructor( + val none: Dp = 0.dp, + val xs: Dp = 0.5.dp, + val sm: Dp = 1.dp, + val md: Dp = 2.dp, + val lg: Dp = 4.dp, + ) + + @Stable + class Size internal constructor( + val s100: Dp = 8.dp, + val s125: Dp = 10.dp, + val s150: Dp = 12.dp, + val s200: Dp = 16.dp, + val s250: Dp = 20.dp, + val s300: Dp = 24.dp, + val s350: Dp = 28.dp, + val s400: Dp = 32.dp, + val s450: Dp = 36.dp, + val s500: Dp = 40.dp, + val s550: Dp = 44.dp, + val s600: Dp = 48.dp, + val s700: Dp = 56.dp, + val s800: Dp = 64.dp, + val s900: Dp = 72.dp, + val s1000: Dp = 80.dp, + val s1100: Dp = 88.dp, + val s1200: Dp = 96.dp, + val s025: Dp = 2.dp, + val s050: Dp = 4.dp, + val card: Card = Card(), + ) { + @Stable + class Card internal constructor( + val sm: Dp = 128.dp, + ) + } + + @Stable + class Spacing internal constructor( + val s100: Dp = 8.dp, + val s125: Dp = 10.dp, + val s150: Dp = 12.dp, + val s200: Dp = 16.dp, + val s250: Dp = 20.dp, + val s300: Dp = 24.dp, + val s350: Dp = 28.dp, + val s400: Dp = 32.dp, + val s450: Dp = 36.dp, + val s500: Dp = 40.dp, + val s550: Dp = 44.dp, + val s600: Dp = 48.dp, + val s700: Dp = 56.dp, + val s800: Dp = 64.dp, + val s900: Dp = 72.dp, + val s1000: Dp = 80.dp, + val s025: Dp = 2.dp, + val s050: Dp = 4.dp, + val s075: Dp = 6.dp, + val none: Dp = 0.dp, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt new file mode 100644 index 0000000000..10e6eb6ccb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt @@ -0,0 +1,112 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.sp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +@Stable +class TangemTypography3 internal constructor(fontFamily: FontFamily) { + val display: Display = Display(fontFamily) + val heading: Heading = Heading(fontFamily) + val body: Body = Body(fontFamily) + val subheading: Subheading = Subheading(fontFamily) + val caption: Caption = Caption(fontFamily) + + @Stable + class Display internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 44.sp, + lineHeight = 52.sp, + letterSpacing = (-0.92).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + } + + @Stable + class Heading internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 33.sp, + letterSpacing = (-0.37).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + val small: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 24.sp, + letterSpacing = (-0.12).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + } + + @Stable + class Body internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 0.02.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + } + + @Stable + class Subheading internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 17.sp, + letterSpacing = 0.07.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + } + + @Stable + class Caption internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.18.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/AccountDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/AccountDetailsScreenTestTags.kt deleted file mode 100644 index ccc4793a3c..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/test/AccountDetailsScreenTestTags.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.core.ui.test - -object AccountDetailsScreenTestTags { - const val MANAGE_TOKENS_BUTTON = "ACCOUNT_DETAILS_SCREEN_MANAGE_TOKENS_BUTTON" -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/HotWalletAccessCodeTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/HotWalletAccessCodeTestTags.kt new file mode 100644 index 0000000000..a71b596eae --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/HotWalletAccessCodeTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object HotWalletAccessCodeTestTags { + const val ACCESS_CODE_INPUT = "HOT_WALLET_ACCESS_CODE_INPUT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/ImportWalletScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/ImportWalletScreenTestTags.kt new file mode 100644 index 0000000000..3e641be3e5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/ImportWalletScreenTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object ImportWalletScreenTestTags { + const val PHRASE_TEXT_FIELD = "IMPORT_WALLET_PHRASE_TEXT_FIELD" + const val PASSPHRASE_TEXT_FIELD = "IMPORT_WALLET_PASSPHRASE_TEXT_FIELD" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt new file mode 100644 index 0000000000..ffd6ed9254 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt @@ -0,0 +1,39 @@ +package com.tangem.core.ui.test + +object TangemPayTestTags { + // Main wallet screen tile (entry point into Tangem Pay) + const val MAIN_SCREEN_TILE = "TANGEM_PAY_MAIN_SCREEN_TILE" + + // Payment account details screen + const val PAYMENT_ACCOUNT_BALANCE = "TANGEM_PAY_PAYMENT_ACCOUNT_BALANCE" + const val PAYMENT_ACCOUNT_CARD_BUTTON = "TANGEM_PAY_PAYMENT_ACCOUNT_CARD_BUTTON" + + // Card details (reveal + copy) + const val CARD_DETAILS_SHOW_BUTTON = "TANGEM_PAY_CARD_DETAILS_SHOW_BUTTON" + const val CARD_DETAILS_HIDE_BUTTON = "TANGEM_PAY_CARD_DETAILS_HIDE_BUTTON" + const val CARD_DETAILS_NUMBER_VALUE = "TANGEM_PAY_CARD_DETAILS_NUMBER_VALUE" + const val CARD_DETAILS_EXPIRATION_VALUE = "TANGEM_PAY_CARD_DETAILS_EXPIRATION_VALUE" + const val CARD_DETAILS_CVC_VALUE = "TANGEM_PAY_CARD_DETAILS_CVC_VALUE" + const val CARD_DETAILS_COPY_NUMBER = "TANGEM_PAY_CARD_DETAILS_COPY_NUMBER" + const val CARD_DETAILS_COPY_EXPIRATION = "TANGEM_PAY_CARD_DETAILS_COPY_EXPIRATION" + const val CARD_DETAILS_COPY_CVC = "TANGEM_PAY_CARD_DETAILS_COPY_CVC" + + // Card management (card page settings) + const val CHANGE_PIN_ROW = "TANGEM_PAY_CHANGE_PIN_ROW" + const val FREEZE_CARD_ROW = "TANGEM_PAY_FREEZE_CARD_ROW" + + // Freeze confirmation bottom sheet + const val FREEZE_CONFIRMATION_SUBMIT_BUTTON = "TANGEM_PAY_FREEZE_CONFIRMATION_SUBMIT_BUTTON" + + // PIN entry screen + const val PIN_SCREEN_TITLE = "TANGEM_PAY_PIN_SCREEN_TITLE" + const val PIN_SCREEN_DESCRIPTION = "TANGEM_PAY_PIN_SCREEN_DESCRIPTION" + const val PIN_INPUT_FIELD = "TANGEM_PAY_PIN_INPUT_FIELD" + const val PIN_SUBMIT_BUTTON = "TANGEM_PAY_PIN_SUBMIT_BUTTON" + const val PIN_ERROR_MESSAGE = "TANGEM_PAY_PIN_ERROR_MESSAGE" + + // PIN success screen + const val PIN_SUCCESS_TITLE = "TANGEM_PAY_PIN_SUCCESS_TITLE" + const val PIN_SUCCESS_DESCRIPTION = "TANGEM_PAY_PIN_SUCCESS_DESCRIPTION" + const val PIN_DONE_BUTTON = "TANGEM_PAY_PIN_DONE_BUTTON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt index f9e953824d..9079c600f0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt @@ -15,4 +15,13 @@ object TokenDetailsScreenTestTags { const val STAKING_TOKEN_AMOUNT = "TOKEN_DETAILS_SCREEN_STAKING_TOKEN_AMOUNT" const val STAKING_REWARD_VALUE = "TOKEN_DETAILS_SCREEN_STAKING_REWARD_VALUE" const val STAKING_CHEVRON_ICON = "TOKEN_DETAILS_SCREEN_STAKING_CHEVRON_ICON" + + const val EXPRESS_STATUS_ITEM = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM" + const val EXPRESS_STATUS_ITEM_TITLE = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_TITLE" + const val EXPRESS_STATUS_ITEM_FROM_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_FROM_ICON" + const val EXPRESS_STATUS_ITEM_FROM_AMOUNT = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_FROM_AMOUNT" + const val EXPRESS_STATUS_ITEM_SWAP_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_SWAP_ICON" + const val EXPRESS_STATUS_ITEM_TO_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_TO_ICON" + const val EXPRESS_STATUS_ITEM_TO_AMOUNT = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_TO_AMOUNT" + const val EXPRESS_STATUS_ITEM_CHEVRON_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_CHEVRON_ICON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TransactionSuccessScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionSuccessScreenTestTags.kt new file mode 100644 index 0000000000..2f2ca91b51 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionSuccessScreenTestTags.kt @@ -0,0 +1,11 @@ +package com.tangem.core.ui.test + +object TransactionSuccessScreenTestTags { + const val CONTAINER = "TRANSACTION_SUCCESS_SCREEN_CONTAINER" + const val TITLE = "TRANSACTION_SUCCESS_SCREEN_TITLE" + const val TRANSACTION_DATE = "TRANSACTION_SUCCESS_SCREEN_DATE" + const val AMOUNT_BLOCK = "TRANSACTION_SUCCESS_SCREEN_AMOUNT_BLOCK" + const val FEE_BLOCK = "TRANSACTION_SUCCESS_SCREEN_FEE_BLOCK" + const val PROVIDER_BLOCK = "TRANSACTION_SUCCESS_SCREEN_PROVIDER_BLOCK" + const val RECIPIENT_BLOCK = "TRANSACTION_SUCCESS_SCREEN_RECIPIENT_BLOCK" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt index 6d178c0cd9..15b8847280 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.test object WalletSettingsScreenTestTags { const val SCREEN_CONTAINER = "WALLET_SETTINGS_SCREEN_CONTAINER" + const val ACCOUNTS_CONTAINER = "WALLET_SETTINGS_SCREEN_ACCOUNTS_CONTAINER" const val SCREEN_ITEM = "WALLET_SETTINGS_SCREEN_ITEM" const val USER_ACCOUNT_ITEM = "WALLET_SETTINGS_USER_ACCOUNT_ITEM" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt index f4e58b8056..d8c3f37a55 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt @@ -4,4 +4,6 @@ object WarningBottomSheetTestTags { const val ICON = "BASE_WARNING_BOTTOM_SHEET_ICON" const val TITLE = "BASE_WARNING_BOTTOM_SHEET_TITLE" const val MESSAGE = "BASE_WARNING_BOTTOM_SHEET_MESSAGE" + const val BUTTON_PRIMARY = "BASE_WARNING_BOTTOM_SHEET_BUTTON_PRIMARY" + const val BUTTON_SECONDARY = "BASE_WARNING_BOTTOM_SHEET_BUTTON_SECONDARY" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountDetailsScreenTestTags.kt new file mode 100644 index 0000000000..cd4c95dbda --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountDetailsScreenTestTags.kt @@ -0,0 +1,9 @@ +package com.tangem.core.ui.test.accounts + +object AccountDetailsScreenTestTags { + + const val ACCOUNT_DETAILS_CONTAINER = "ACCOUNT_DETAILS_SCREEN_CONTAINER" + const val MANAGE_TOKENS_BUTTON = "ACCOUNT_DETAILS_SCREEN_MANAGE_TOKENS_BUTTON" + const val EDIT_ACCOUNT_BUTTON = "ACCOUNT_DETAILS_SCREEN_EDIT_ACCOUNT_BUTTON" + const val ARCHIVE_ACCOUNT_BUTTON = "ACCOUNT_DETAILS_SCREEN_ARCHIVE_ACCOUNT_BUTTON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountInfoEditScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountInfoEditScreenTestTags.kt new file mode 100644 index 0000000000..940f3eebe1 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountInfoEditScreenTestTags.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.test.accounts + +object AccountInfoEditScreenTestTags { + const val ACCOUNT_DETAILS_CONTAINER = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_DETAILS_CONTAINER" + const val ADD_ACCOUNT_BUTTON = "ACCOUNT_INFO_EDIT_SCREEN_ADD_ACCOUNT_BUTTON" + const val COLOR_OPTION = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_INFO_COLOR_OPTION" + const val TYPE_OPTION = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_INFO_TYPE_OPTION" + const val SELECTED_ICON = "ACCOUNT_INFO_EDIT_SCREEN_SELECTED_ICON" + const val NAME_FIELD = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_INFO_NAME_FIELD" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountRowTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountRowTestTags.kt new file mode 100644 index 0000000000..680509ff25 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountRowTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test.accounts + +object AccountRowTestTags { + const val ICON = "ACCOUNT_ROW_ICON" + const val TITLE = "ACCOUNT_ROW_TITLE" + const val SUBTITLE = "ACCOUNT_ROW_SUBTITLE" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/ArchivedAccountsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/ArchivedAccountsScreenTestTags.kt new file mode 100644 index 0000000000..9bb6ef093f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/ArchivedAccountsScreenTestTags.kt @@ -0,0 +1,8 @@ +package com.tangem.core.ui.test.accounts + +object ArchivedAccountsScreenTestTags { + + const val ARCHIVED_ACCOUNTS_SCREEN_CONTAINER = "ARCHIVED_ACCOUNTS_LIST_CONTAINER" + const val ARCHIVED_ACCOUNT_ITEM = "ARCHIVED_ACCOUNTS_LIST_ARCHIVED_ACCOUNT_ITEM" + const val RESTORE_BUTTON = "ARCHIVED_ACCOUNTS_LIST_RESTORE_BUTTON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt index 9354511464..6c5be9aecd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt @@ -5,6 +5,7 @@ import com.tangem.utils.extensions.isToday import com.tangem.utils.extensions.isYesterday import org.joda.time.DateTime import org.joda.time.DateTimeZone +import org.joda.time.LocalDate import org.joda.time.format.DateTimeFormatter /** @@ -46,16 +47,26 @@ fun Long.formatAsDateTime(formatter: DateTimeFormatter): String { * @param now The current date to compare against. * @return A [FormattedDate] subclass. */ -@Suppress("MagicNumber") fun getFormattedDate(createdAt: String, now: DateTime): FormattedDate { val pastDateUtc = try { DateTime.parse(createdAt) } catch (_: Exception) { return FormattedDate.FullDate(createdAt) } + return getFormattedDate(pastDateUtc = pastDateUtc, now = now) +} +/** + * Compares the given past date to [now] and returns a [FormattedDate] describing the difference. + * + * @param pastDateUtc Past date in UTC. + * @param now The current date to compare against. + */ +@Suppress("MagicNumber") +fun getFormattedDate(pastDateUtc: DateTime, now: DateTime): FormattedDate { val pastDateLocal = pastDateUtc.withZone(DateTimeZone.getDefault()) - val isToday = pastDateLocal.isToday() + val nowLocal = now.withZone(DateTimeZone.getDefault()) + val isToday = LocalDate(pastDateLocal) == LocalDate(nowLocal) val diffInMillis = now.millis - pastDateUtc.millis val diffInMinutes = diffInMillis / (1000 * 60) diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/FormattedDateMapper.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/FormattedDateMapper.kt new file mode 100644 index 0000000000..ef715af57c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/FormattedDateMapper.kt @@ -0,0 +1,55 @@ +package com.tangem.core.ui.utils + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.utils.StringsSigns +import org.joda.time.DateTime +import org.joda.time.DateTimeZone + +/** + * Maps an ISO 8601 date string into a [TextReference] with a human-friendly "time ago" label. + */ +fun mapFormattedDate(createdAt: String, now: DateTime = DateTime.now()): TextReference { + val formattedDate = runCatching { + getFormattedDate(createdAt = createdAt, now = now) + }.getOrElse { FormattedDate.FullDate(createdAt) } + + return formattedDate.toTextReference() +} + +/** + * Maps an epoch millisecond [timestamp] into a [TextReference] with a human-friendly "time ago" label. + */ +fun mapFormattedDate(timestamp: Long, now: DateTime = DateTime.now()): TextReference { + val formattedDate = runCatching { + getFormattedDate(pastDateUtc = DateTime(timestamp, DateTimeZone.UTC), now = now) + }.getOrElse { FormattedDate.FullDate(timestamp.toString()) } + + return formattedDate.toTextReference() +} + +private fun FormattedDate.toTextReference(): TextReference = when (this) { + is FormattedDate.FullDate -> TextReference.Str(value = date) + is FormattedDate.HoursAgo -> TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = hours, + formatArgs = wrappedList(hours), + ) + is FormattedDate.MinutesAgo -> TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = minutes, + formatArgs = wrappedList(minutes), + ) + is FormattedDate.Today -> TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str(time), + ), + ), + ) +} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_alert_triange_24.xml b/core/ui/src/main/res/drawable/ic_alert_triange_24.xml new file mode 100644 index 0000000000..6442020ee2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_alert_triange_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_arrow_down_20.xml b/core/ui/src/main/res/drawable/ic_arrow_down_20.xml new file mode 100644 index 0000000000..f3206755ec --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_arrow_down_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_arrow_expand_24.xml b/core/ui/src/main/res/drawable/ic_arrow_expand_24.xml new file mode 100644 index 0000000000..ce56686a2c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_arrow_expand_24.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_chevron_small_left_24.xml b/core/ui/src/main/res/drawable/ic_chevron_small_left_24.xml new file mode 100644 index 0000000000..b44b76019e --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_chevron_small_left_24.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_chewron_up_20.xml b/core/ui/src/main/res/drawable/ic_chewron_up_20.xml new file mode 100644 index 0000000000..aeb0f01317 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_chewron_up_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_cloud_fill_16.xml b/core/ui/src/main/res/drawable/ic_cloud_fill_16.xml new file mode 100644 index 0000000000..fe6e3ff3f8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_cloud_fill_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_credit_card_20.xml b/core/ui/src/main/res/drawable/ic_credit_card_20.xml new file mode 100644 index 0000000000..800492e8b1 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_credit_card_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_badge_16.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_badge_16.xml new file mode 100644 index 0000000000..5ab98c661f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_badge_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml new file mode 100644 index 0000000000..fac7eb89dd --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml new file mode 100644 index 0000000000..a1e00c61f2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml new file mode 100644 index 0000000000..57afa69fb6 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml new file mode 100644 index 0000000000..71ad60eda8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_key_card_20.xml b/core/ui/src/main/res/drawable/ic_key_card_20.xml new file mode 100644 index 0000000000..1a0274c0cd --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_key_card_20.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_limit_20.xml b/core/ui/src/main/res/drawable/ic_limit_20.xml new file mode 100644 index 0000000000..4fc3491e34 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_limit_20.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_select_choice_20.xml b/core/ui/src/main/res/drawable/ic_select_choice_20.xml new file mode 100644 index 0000000000..514de3e3a4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_select_choice_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_sort_24.xml b/core/ui/src/main/res/drawable/ic_sort_24.xml index 538a24d8e0..f2b3124b42 100644 --- a/core/ui/src/main/res/drawable/ic_sort_24.xml +++ b/core/ui/src/main/res/drawable/ic_sort_24.xml @@ -3,7 +3,18 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + + diff --git a/core/ui/src/main/res/drawable/ic_staking_40.xml b/core/ui/src/main/res/drawable/ic_staking_40.xml new file mode 100644 index 0000000000..07c94be806 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_staking_40.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_staking_disable_40.xml b/core/ui/src/main/res/drawable/ic_staking_disable_40.xml new file mode 100644 index 0000000000..e7442dc61c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_staking_disable_40.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_sync_56.xml b/core/ui/src/main/res/drawable/ic_sync_56.xml new file mode 100644 index 0000000000..112dd64452 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_sync_56.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_update_32.xml b/core/ui/src/main/res/drawable/ic_update_32.xml new file mode 100644 index 0000000000..3be862185a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_update_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_yield_40.xml b/core/ui/src/main/res/drawable/ic_yield_40.xml new file mode 100644 index 0000000000..24692470e4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_yield_40.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_yield_disabling_40.xml b/core/ui/src/main/res/drawable/ic_yield_disabling_40.xml new file mode 100644 index 0000000000..27fc408f91 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_yield_disabling_40.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_usdc_16.xml b/core/ui/src/main/res/drawable/img_usdc_16.xml new file mode 100644 index 0000000000..460fa8ad13 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_usdc_16.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/img_visa_card_48x32.webp b/core/ui/src/main/res/drawable/img_visa_card_48x32.webp new file mode 100644 index 0000000000..8fa2d328ee Binary files /dev/null and b/core/ui/src/main/res/drawable/img_visa_card_48x32.webp differ diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt index 7fa8bcd9ce..0be823f7d9 100644 --- a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt @@ -1,6 +1,12 @@ package com.tangem.core.ui.utils +import android.text.format.DateFormat import com.google.common.truth.Truth +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -11,6 +17,17 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DateTimeFormattersTest { + @BeforeEach + fun setUp() { + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + } + + @AfterEach + fun tearDown() { + unmockkStatic(DateFormat::class) + } + @Test fun `converts LLLL to MMMM - full standalone month pattern that crashes on Chinese locale`() { // Arrange diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt index 6e145b2371..626df6c6b2 100644 --- a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt @@ -1,8 +1,15 @@ package com.tangem.core.ui.utils +import android.text.format.DateFormat import com.google.common.truth.Truth +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic import org.joda.time.DateTime import org.joda.time.DateTimeZone +import org.joda.time.format.DateTimeFormatterBuilder import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -19,11 +26,23 @@ class DateUtilsTest { fun setUp() { defaultTimeZone = DateTimeZone.getDefault() DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow")) + + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + + mockkObject(DateTimeFormatters) + every { DateTimeFormatters.timeFormatter } returns DateTimeFormatterBuilder() + .appendHourOfDay(2) + .appendLiteral(':') + .appendMinuteOfHour(2) + .toFormatter() } @AfterEach fun tearDown() { DateTimeZone.setDefault(defaultTimeZone) + unmockkObject(DateTimeFormatters) + unmockkStatic(DateFormat::class) } @Test diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/FormattedDateMapperTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/FormattedDateMapperTest.kt new file mode 100644 index 0000000000..4e6ebc7f46 --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/FormattedDateMapperTest.kt @@ -0,0 +1,234 @@ +package com.tangem.core.ui.utils + +import android.text.format.DateFormat +import com.google.common.truth.Truth +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.utils.StringsSigns +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.format.DateTimeFormatterBuilder +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FormattedDateMapperTest { + + private lateinit var defaultTimeZone: DateTimeZone + + private val now = createDateTime(day = 14, hour = 12) + + @BeforeEach + fun setUp() { + defaultTimeZone = DateTimeZone.getDefault() + DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow")) + + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + + mockkObject(DateTimeFormatters) + every { DateTimeFormatters.timeFormatter } returns DateTimeFormatterBuilder() + .appendHourOfDay(2) + .appendLiteral(':') + .appendMinuteOfHour(2) + .toFormatter() + } + + @AfterEach + fun tearDown() { + DateTimeZone.setDefault(defaultTimeZone) + unmockkObject(DateTimeFormatters) + unmockkStatic(DateFormat::class) + } + + // region String overload + + @Test + fun `GIVEN iso string less than a minute ago WHEN mapFormattedDate THEN return minutes PluralRes with count 1`() { + val createdAt = now.minusSeconds(30).toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 1, + formatArgs = wrappedList(1), + ), + ) + } + + @Test + fun `GIVEN iso string 30 minutes ago WHEN mapFormattedDate THEN return minutes PluralRes with count 30`() { + val createdAt = now.minusMinutes(30).toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 30, + formatArgs = wrappedList(30), + ), + ) + } + + @Test + fun `GIVEN iso string 3 hours ago today WHEN mapFormattedDate THEN return hours PluralRes with count 3`() { + val createdAt = now.minusHours(3).toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = 3, + formatArgs = wrappedList(3), + ), + ) + } + + @Test + fun `GIVEN iso string today but past 12 hours WHEN mapFormattedDate THEN return Combined with today and local time`() { + val pastDate = createDateTime(day = 14, hour = 0) + val createdAt = pastDate.toString() + val nowInTest = createDateTime(day = 14, hour = 12) + + val result = mapFormattedDate(createdAt = createdAt, now = nowInTest) + + Truth.assertThat(result).isEqualTo( + TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ), + ) + } + + @Test + fun `GIVEN iso string from a previous day WHEN mapFormattedDate THEN return Str FullDate`() { + val pastDate = createDateTime(day = 10, hour = 9) + val createdAt = pastDate.toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isInstanceOf(TextReference.Str::class.java) + } + + @Test + fun `GIVEN malformed iso string WHEN mapFormattedDate THEN return Str with original value`() { + val malformed = "2025/10/14T12:00:00.000Z" + + val result = mapFormattedDate(createdAt = malformed, now = now) + + Truth.assertThat(result).isEqualTo(TextReference.Str(value = malformed)) + } + + // endregion + + // region Long overload + + @Test + fun `GIVEN timestamp less than a minute ago WHEN mapFormattedDate THEN return minutes PluralRes with count 1`() { + val timestamp = now.minusSeconds(30).millis + + val result = mapFormattedDate(timestamp = timestamp, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 1, + formatArgs = wrappedList(1), + ), + ) + } + + @Test + fun `GIVEN timestamp 45 minutes ago WHEN mapFormattedDate THEN return minutes PluralRes with count 45`() { + val timestamp = now.minusMinutes(45).millis + + val result = mapFormattedDate(timestamp = timestamp, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 45, + formatArgs = wrappedList(45), + ), + ) + } + + @Test + fun `GIVEN timestamp 5 hours ago today WHEN mapFormattedDate THEN return hours PluralRes with count 5`() { + val timestamp = now.minusHours(5).millis + + val result = mapFormattedDate(timestamp = timestamp, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = 5, + formatArgs = wrappedList(5), + ), + ) + } + + @Test + fun `GIVEN timestamp today but past 12 hours WHEN mapFormattedDate THEN return Combined with today and local time`() { + val pastDate = createDateTime(day = 14, hour = 0) + val nowInTest = createDateTime(day = 14, hour = 12) + + val result = mapFormattedDate(timestamp = pastDate.millis, now = nowInTest) + + Truth.assertThat(result).isEqualTo( + TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ), + ) + } + + @Test + fun `GIVEN timestamp from a previous day WHEN mapFormattedDate THEN return Str FullDate`() { + val pastDate = createDateTime(day = 10, hour = 9) + + val result = mapFormattedDate(timestamp = pastDate.millis, now = now) + + Truth.assertThat(result).isInstanceOf(TextReference.Str::class.java) + } + + // endregion + + private fun createDateTime(day: Int, hour: Int): DateTime { + return DateTime( + /* year = */ 2025, + /* monthOfYear = */ 10, + /* dayOfMonth = */ day, + /* hourOfDay = */ hour, + /* minuteOfHour = */ 0, + /* secondOfMinute = */ 0, + /* millisOfSecond = */ 0, + /* zone = */ DateTimeZone.UTC, + ) + } +} \ No newline at end of file diff --git a/core/ui/token-gen/.gitignore b/core/ui/token-gen/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/core/ui/token-gen/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/core/ui/token-gen/README.md b/core/ui/token-gen/README.md new file mode 100644 index 0000000000..62736c8beb --- /dev/null +++ b/core/ui/token-gen/README.md @@ -0,0 +1,21 @@ +# token-gen + +Generates Kotlin (Jetpack Compose) source files from design tokens defined in the `ds-tokens` git submodule. + +## Updating tokens + +1. Update the `ds-tokens` submodule to the latest commit: + ```bash + git submodule update --remote core/ui/ds-tokens + ``` +2. Re-run the build: + ```bash + cd core/ui/token-gen && npm run build + ``` +3. Commit both the submodule pointer and generated files. + +## How it works + +The script uses [Style Dictionary v5](https://styledictionary.com/) with [@tokens-studio/sd-transforms](https://github.com/tokens-studio/sd-transforms) to read JSON token files from `core/ui/ds-tokens/tokens/` and generate Kotlin files into `core/ui/src/main/java/com/tangem/core/ui/res/generated/`. + +All generated files are written to `com.tangem.core.ui.res.generated` and should not be edited manually. diff --git a/core/ui/token-gen/build-tokens.mjs b/core/ui/token-gen/build-tokens.mjs new file mode 100644 index 0000000000..9d30c49158 --- /dev/null +++ b/core/ui/token-gen/build-tokens.mjs @@ -0,0 +1,915 @@ +import StyleDictionary from 'style-dictionary'; +import { register, getTransforms } from '@tokens-studio/sd-transforms'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// ── Paths ────────────────────────────────────────────────────────────────────── +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const tokensDir = path.join(__dirname, '..', 'ds-tokens', 'tokens'); +const outputDir = path.join( + __dirname, '..', 'src', 'main', 'java', 'com', 'tangem', 'core', 'ui', 'res', 'generated', +); + +const PACKAGE = 'com.tangem.core.ui.res.generated'; + +fs.mkdirSync(outputDir, { recursive: true }); + +// ── Register sd-transforms ───────────────────────────────────────────────────── +// Use CSS platform for sd-transforms to get ts/color/css/hexrgba (resolves rgba to hex). +// Compose color conversion is done in the format function instead of as a transform, +// because the color/composeColor transform interferes with rgba reference resolution. +await register(StyleDictionary, { platform: 'css' }); + +// ── Token set definitions ────────────────────────────────────────────────────── +// Reference-only sets (provide values for other tokens but not in the output) +const coreSets = [ + 'core/palette', + 'core/font', + 'core/dimension', +]; + +// Theme-independent semantic sets +const sizeSets = [ + 'semantic/size/opacity', + 'semantic/size/blur', + 'semantic/size/border', + 'semantic/size/size', + 'semantic/size/spacing', +]; + +const fontSets = [ + 'semantic/font/sizes/android', + 'semantic/font/styles', +]; + +const sharedSets = [ + 'semantic/theme/shadows', + 'semantic/theme/gradient', +]; + +// Theme-specific sets +// Note: material variant files (glass/blur/solid) define colliding paths and are excluded. +// The material color tokens come from materials/light and materials/dark instead. +const themeBuilds = { + Light: { + sets: [ + ...coreSets, ...sizeSets, ...fontSets, ...sharedSets, + 'semantic/theme/light', + 'semantic/theme/materials/light', + ], + }, + Dark: { + sets: [ + ...coreSets, ...sizeSets, ...fontSets, ...sharedSets, + 'semantic/theme/dark', + 'semantic/theme/materials/dark', + ], + }, +}; + +// For theme-independent builds (dimensions, typography, etc.) +const sharedBuildSets = [...coreSets, ...sizeSets, ...fontSets, ...sharedSets]; + +for (const [theme, { sets }] of Object.entries(themeBuilds)) { + console.log(`${theme} theme: ${sets.length} token sets`); +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Convert token path segments to a camelCase property name */ +function toCamelCase(segments) { + return segments + .map((seg, i) => { + // kebab-case → camelCase + const cleaned = seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); + if (i === 0) return cleaned; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); + }) + .join(''); +} + +/** Check if a token is from core/palette, dimension, or gradient source sets (not for output) */ +function isSourceOnlyToken(token) { + const top = token.path[0]; + return top === 'palette' || top === 'dimension' || top === 'gradient'; +} + +/** + * Convert a resolved CSS color string to Compose Color(0xAARRGGBB). + * Handles: #RRGGBB, #AARRGGBB, rgba(r, g, b, a), rgb(r, g, b) + */ +function toComposeColor(value, tokenPath = '') { + if (typeof value !== 'string') { + throw new Error(`Unrecognized color value for ${tokenPath}: ${JSON.stringify(value)}`); + } + + // #RRGGBB + const hex6 = value.match(/^#([0-9a-fA-F]{6})$/); + if (hex6) return `Color(0xFF${hex6[1].toUpperCase()})`; + + // #AARRGGBB (8-digit hex) + const hex8 = value.match(/^#([0-9a-fA-F]{8})$/); + if (hex8) return `Color(0x${hex8[1].toUpperCase()})`; + + // rgba(r, g, b, a) + const rgba = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/); + if (rgba) { + const r = parseInt(rgba[1]).toString(16).padStart(2, '0').toUpperCase(); + const g = parseInt(rgba[2]).toString(16).padStart(2, '0').toUpperCase(); + const b = parseInt(rgba[3]).toString(16).padStart(2, '0').toUpperCase(); + const a = Math.round(parseFloat(rgba[4]) * 255).toString(16).padStart(2, '0').toUpperCase(); + return `Color(0x${a}${r}${g}${b})`; + } + + // rgb(r, g, b) + const rgb = value.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); + if (rgb) { + const r = parseInt(rgb[1]).toString(16).padStart(2, '0').toUpperCase(); + const g = parseInt(rgb[2]).toString(16).padStart(2, '0').toUpperCase(); + const b = parseInt(rgb[3]).toString(16).padStart(2, '0').toUpperCase(); + return `Color(0xFF${r}${g}${b})`; + } + + // Transparent + if (value === 'transparent' || value === '#00000000') return 'Color(0x00000000)'; + + throw new Error(`Unrecognized color format for ${tokenPath}: "${value}"`); +} + +/** Group tokens by their first N path segments */ +function groupByPath(tokens, depth = 1) { + const groups = {}; + for (const token of tokens) { + const key = token.path.slice(0, depth).join('.'); + if (!groups[key]) groups[key] = []; + groups[key].push(token); + } + return groups; +} + +/** + * Build a tree of nested objects from entries. + * Each entry: { path: string[], value: string }. + * The last segment is the property name; preceding segments become nested objects. + */ +function buildPropertyTree(entries) { + const root = { props: [], children: new Map() }; + + for (const { path, value } of entries) { + let node = root; + for (let i = 0; i < path.length - 1; i++) { + const seg = path[i]; + if (!node.children.has(seg)) { + node.children.set(seg, { props: [], children: new Map() }); + } + node = node.children.get(seg); + } + const propName = path[path.length - 1]; + const existingIdx = node.props.findIndex(p => p.name === propName); + if (existingIdx >= 0) { + console.warn(` ⚠ Duplicate property path: ${path.join('.')} — overwriting`); + node.props[existingIdx] = { name: propName, value }; + } else { + node.props.push({ name: propName, value }); + } + } + + return root; +} + +/** + * Render a property tree as Kotlin nested objects. + * Returns an array of indented lines. + */ +function renderTree(node, indent = 1) { + const pad = ' '.repeat(indent); + const lines = []; + + for (const { name, value } of node.props) { + lines.push(`${pad}val ${name} = ${value}`); + } + + for (const [name, child] of node.children) { + if (lines.length > 0) lines.push(''); + lines.push(`${pad}object ${name} {`); + lines.push(...renderTree(child, indent + 1)); + lines.push(`${pad}}`); + } + + return lines; +} + +/** + * Render a @Stable class tree for dimension tokens. + * Each node with children becomes a nested @Stable class. + * Props carry { default, type } values. + */ +function renderStableDimenClass(className, node, indent) { + const pad = ' '.repeat(indent); + const pad1 = ' '.repeat(indent + 1); + const lines = []; + + lines.push(`${pad}@Stable`); + lines.push(`${pad}class ${className} internal constructor(`); + + for (const { name, value } of node.props) { + lines.push(`${pad1}val ${kotlinSafe(name)}: ${value.type} = ${value.default},`); + } + for (const [childName, childNode] of node.children) { + const typeName = capitalize(childName); + const propName = kotlinSafe(childName.charAt(0).toLowerCase() + childName.slice(1)); + lines.push(`${pad1}val ${propName}: ${typeName} = ${typeName}(),`); + } + + if (node.children.size === 0) { + lines.push(`${pad})`); + } else { + lines.push(`${pad}) {`); + + let first = true; + for (const [childName, childNode] of node.children) { + if (!first) lines.push(''); + first = false; + lines.push(...renderStableDimenClass(capitalize(childName), childNode, indent + 1)); + } + + lines.push(`${pad}}`); + } + + return lines; +} + +/** + * Capitalize the first letter of a string. + */ +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Convert a kebab-case segment to PascalCase (for object names). + */ +function toPascalCase(seg) { + return seg + .split('-') + .map(part => capitalize(part)) + .join(''); +} + +/** + * Wrap a name in backticks if it starts with a digit or is a Kotlin hard keyword. + */ +const KOTLIN_HARD_KEYWORDS = new Set([ + 'as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun', + 'if', 'in', 'interface', 'is', 'null', 'object', 'package', 'return', + 'super', 'this', 'throw', 'true', 'try', 'typealias', 'typeof', 'val', + 'var', 'when', 'while', +]); + +function kotlinSafe(name) { + if (/^\d/.test(name) || KOTLIN_HARD_KEYWORDS.has(name)) return `\`${name}\``; + return name; +} + +// ── TangemColors3 helpers ───────────────────────────────────────────────────── + +/** Shared structure tree for TangemColors3, computed from light theme codeSyntax. */ +let colors3StructureTree = null; + +/** Extract codeSyntax.Android path, stripping TangemTheme.colors3. prefix. */ +function getAndroidCodeSyntax(token) { + const ext = token.$extensions?.['com.figma.codeSyntax']; + if (!ext?.Android) return null; + const prefix = 'TangemTheme.colors3.'; + const android = ext.Android; + return android.startsWith(prefix) ? android.slice(prefix.length) : null; +} + +/** Get the token's property path for the class tree from codeSyntax, or fallback to JSON path. */ +function colorTokenClassPath(token) { + const cs = getAndroidCodeSyntax(token); + if (cs) return cs.split('.'); + // Fallback for material tokens (no codeSyntax): use JSON path minus 'color' prefix + const pathSegs = token.path.slice(1); // remove 'color' + return pathSegs.map(seg => seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())); +} + +/** + * Extract a simple palette reference from a token's original (unresolved) value. + * Returns the reference string like "palette.neutral.95" or null for complex values. + */ +function extractPaletteRef(token) { + const original = token.original?.$value; + if (typeof original !== 'string') return null; + const match = original.match(/^\{(palette\.[^}]+)\}$/); + return match ? match[1] : null; +} + +/** + * Convert a palette reference path to Kotlin code referencing TangemColorPalette. + * e.g., "palette.neutral.95" → "TangemColorPalette.Neutral.`95`" + * e.g., "palette.opaque.base-black.60" → "TangemColorPalette.Opaque.BaseBlack.`60`" + */ +function paletteRefToKotlin(refPath) { + const parts = refPath.split('.').slice(1); // drop "palette" + const objParts = parts.slice(0, -1).map(seg => toPascalCase(seg)); + const leaf = parts[parts.length - 1].replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); + return `TangemColorPalette.${objParts.join('.')}.${kotlinSafe(leaf)}`; +} + +/** Get Kotlin value for a color token: palette reference if simple, else resolved Color literal. */ +function paletteRefOrColor(token) { + const ref = extractPaletteRef(token); + if (ref) return paletteRefToKotlin(ref); + return toComposeColor(token.$value, token.path.join('.')); +} + +/** + * Build a class structure tree from color tokens using codeSyntax paths. + * Each node: { props: [{name, jsonPath}], children: Map } + */ +function buildClassStructureTree(tokens) { + const root = { props: [], children: new Map() }; + + for (const token of tokens) { + const classPath = colorTokenClassPath(token); + const jsonPath = token.path.join('.'); + + let node = root; + for (let i = 0; i < classPath.length - 1; i++) { + const seg = classPath[i]; + if (!node.children.has(seg)) { + node.children.set(seg, { props: [], children: new Map() }); + } + node = node.children.get(seg); + } + + const propName = classPath[classPath.length - 1]; + const existingIdx = node.props.findIndex(p => p.name === propName); + if (existingIdx >= 0) { + console.warn(` ⚠ Duplicate codeSyntax path: ${classPath.join('.')} (${jsonPath}) — overwriting`); + node.props[existingIdx] = { name: propName, jsonPath }; + } else { + node.props.push({ name: propName, jsonPath }); + } + } + + return root; +} + +/** + * Resolve conflicts where a name appears as both a leaf property and a child class. + * Resolution: move the leaf into the child as "default". + */ +function resolveClassTreeConflicts(node) { + const propsToRemove = []; + + for (const [childName, childNode] of node.children) { + const conflictIdx = node.props.findIndex(p => p.name === childName); + if (conflictIdx >= 0) { + const prop = node.props[conflictIdx]; + console.log(` ℹ Conflict resolved: "${childName}" is both property and class → moved to "${childName}.default"`); + childNode.props.unshift({ name: 'default', jsonPath: prop.jsonPath }); + propsToRemove.push(conflictIdx); + } + } + + for (const idx of propsToRemove.sort((a, b) => b - a)) { + node.props.splice(idx, 1); + } + + for (const child of node.children.values()) { + resolveClassTreeConflicts(child); + } +} + +/** + * Render a @Stable class with mutableStateOf pattern for Compose theme colors. + * Returns array of Kotlin source lines. + */ +function renderStableClass(className, node, indent = 0) { + const pad = ' '.repeat(indent); + const pad1 = ' '.repeat(indent + 1); + const pad2 = ' '.repeat(indent + 2); + const lines = []; + + lines.push(`${pad}@Stable`); + lines.push(`${pad}class ${className} internal constructor(`); + + for (const { name } of node.props) { + lines.push(`${pad1}${kotlinSafe(name)}: Color,`); + } + for (const [childName] of node.children) { + lines.push(`${pad1}val ${childName}: ${capitalize(childName)},`); + } + + lines.push(`${pad}) {`); + + // mutableStateOf delegates for leaf Color props + if (node.props.length > 0) { + for (const { name } of node.props) { + const safe = kotlinSafe(name); + lines.push(`${pad1}var ${safe} by mutableStateOf(${safe})`); + lines.push(`${pad2}private set`); + } + } + + // Nested child classes + for (const [childName, childNode] of node.children) { + lines.push(''); + lines.push(...renderStableClass(capitalize(childName), childNode, indent + 1)); + } + + // update() function + lines.push(''); + lines.push(`${pad1}fun update(other: ${className}) {`); + for (const { name } of node.props) { + const safe = kotlinSafe(name); + lines.push(`${pad2}${safe} = other.${safe}`); + } + for (const [childName] of node.children) { + lines.push(`${pad2}${childName}.update(other.${childName})`); + } + lines.push(`${pad1}}`); + + lines.push(`${pad}}`); + + return lines; +} + +/** + * Render the content lines of a factory constructor call (param assignments + child constructors). + */ +function renderFactoryContent(classPath, node, valueMap, indent) { + const pad = ' '.repeat(indent); + const lines = []; + + for (const { name, jsonPath } of node.props) { + const value = valueMap.get(jsonPath); + if (!value) console.warn(` ⚠ No value for jsonPath "${jsonPath}" (property: ${name})`); + lines.push(`${pad}${kotlinSafe(name)} = ${value || 'Color.Unspecified'},`); + } + + for (const [childName, childNode] of node.children) { + const childClassPath = `${classPath}.${capitalize(childName)}`; + lines.push(`${pad}${childName} = ${childClassPath}(`); + lines.push(...renderFactoryContent(childClassPath, childNode, valueMap, indent + 1)); + lines.push(`${pad}),`); + } + + return lines; +} + +const FILE_SUPPRESS = '@file:Suppress("all")'; + +// ── Custom formats ───────────────────────────────────────────────────────────── + +/** + * Kotlin format for palette tokens. + * Generates nested objects: object Base { val black = ... }, object Neutral { val `5` = ... }, etc. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-palette', + format: ({ dictionary }) => { + const paletteTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && t.path[0] === 'palette', + ); + + const entries = paletteTokens.map(token => { + // palette.base.black → ["Base", "black"] + // palette.neutral.5 → ["Neutral", "`5`"] + // palette.opaque.base-black.5 → ["Opaque", "BaseBlack", "`5`"] + const segments = token.path.slice(1); // drop "palette" + const path = segments.map((seg, i) => { + if (i < segments.length - 1) { + // intermediate segments → PascalCase object names + return toPascalCase(seg); + } + // leaf segment → property name, backtick-wrap if starts with digit + const camel = seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); + return kotlinSafe(camel); + }); + + const value = toComposeColor(token.$value, token.path.join('.')); + return { path, value }; + }); + + const tree = buildPropertyTree(entries); + const body = renderTree(tree); + + return [ + FILE_SUPPRESS, + '', + `package ${PACKAGE}`, + '', + 'import androidx.compose.ui.graphics.Color', + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + 'internal object TangemColorPalette {', + body.join('\n'), + '}', + '', + ].join('\n'); + }, +}); + +/** + * Kotlin format for TangemDimens3 — structured dimension tokens as @Immutable data class. + * Generates nested @Immutable data classes from codeSyntax.Android paths (prefix: TangemTheme.dimens3.). + * Includes spacing, size, borderRadius, borderWidth, blur, and semantic opacity tokens. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-dimens3', + format: ({ dictionary }) => { + const prefix = 'TangemTheme.dimens3.'; + + const entries = []; + for (const token of dictionary.allTokens) { + const ext = token.$extensions?.['com.figma.codeSyntax']; + if (!ext?.Android?.startsWith(prefix)) continue; + + const codePath = ext.Android.slice(prefix.length); + const segPath = codePath.split('.'); + + const raw = parseFloat(token.$value); + const tp = token.path.join('.'); + if (isNaN(raw)) throw new Error(`Non-numeric value for ${tp}: "${token.$value}"`); + + // Opacity tokens → Float, all others → Dp + const isOpacity = token.$type === 'opacity'; + const value = isOpacity ? `${raw}f` : `${raw}.dp`; + const type = isOpacity ? 'Float' : 'Dp'; + + entries.push({ path: segPath, value, type }); + } + + const tree = buildPropertyTree(entries.map(e => ({ + path: e.path, + value: { default: e.value, type: e.type }, + }))); + + const classLines = renderStableDimenClass('TangemDimens3', tree, 0); + + return [ + FILE_SUPPRESS, + '', + `package ${PACKAGE}`, + '', + 'import androidx.compose.runtime.Stable', + 'import androidx.compose.ui.unit.Dp', + 'import androidx.compose.ui.unit.dp', + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + ...classLines, + '', + ].join('\n'); + }, +}); + +/** + * Kotlin format for TangemTypography3 — @Stable class with nested categories. + * Generates a class taking FontFamily, with nested classes for each typography category + * (display, heading, body, subheading, caption). + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-typography3', + format: ({ dictionary }) => { + const typoTokens = dictionary.allTokens.filter( + t => t.$type === 'typography' && !isSourceOnlyToken(t), + ); + + // Group by category (path[1]: display, heading, body, subheading, caption) + const categories = new Map(); + for (const token of typoTokens) { + const category = token.path[1]; + if (!categories.has(category)) categories.set(category, []); + categories.get(category).push(token); + } + + // Build nested class lines + const outerProps = []; + const innerClasses = []; + + for (const [category, tokens] of categories) { + const className = capitalize(category); + const propName = category; + const isHeading = category === 'display' || category === 'heading'; + + outerProps.push(` val ${propName}: ${className} = ${className}(fontFamily)`); + + const classLines = [` @Stable`, ` class ${className} internal constructor(fontFamily: FontFamily) {`]; + + for (const token of tokens) { + const size = token.path[2]; // medium, small, etc. + const v = token.$value; + const tp = token.path.join('.'); + + if (!v.fontWeight) throw new Error(`Missing fontWeight for ${tp}`); + const fontWeight = mapFontWeight(v.fontWeight); + const fontSize = parseFloat(v.fontSize); + if (isNaN(fontSize)) throw new Error(`Non-numeric fontSize for ${tp}: "${v.fontSize}"`); + const lineHeight = parseFloat(v.lineHeight); + if (isNaN(lineHeight)) throw new Error(`Non-numeric lineHeight for ${tp}: "${v.lineHeight}"`); + const letterSpacing = parseFloat(v.letterSpacing); + if (isNaN(letterSpacing)) throw new Error(`Non-numeric letterSpacing for ${tp}: "${v.letterSpacing}"`); + + const spacingLiteral = letterSpacing < 0 ? `(${letterSpacing})` : `${letterSpacing}`; + + classLines.push(` val ${size}: TextStyle = TextStyle(`); + classLines.push(` fontFamily = fontFamily,`); + classLines.push(` fontWeight = ${fontWeight},`); + classLines.push(` fontSize = ${fontSize}.sp,`); + classLines.push(` lineHeight = ${lineHeight}.sp,`); + classLines.push(` letterSpacing = ${spacingLiteral}.sp,`); + classLines.push(` lineHeightStyle = LineHeightStyle(`); + classLines.push(` alignment = LineHeightStyle.Alignment.Center,`); + classLines.push(` trim = LineHeightStyle.Trim.None,`); + classLines.push(` ),`); + if (isHeading) { + classLines.push(` lineBreak = LineBreak.Heading,`); + } + classLines.push(` )`); + } + + classLines.push(` }`); + innerClasses.push(classLines.join('\n')); + } + + return [ + FILE_SUPPRESS, + '', + `package ${PACKAGE}`, + '', + 'import androidx.compose.runtime.Stable', + 'import androidx.compose.ui.text.TextStyle', + 'import androidx.compose.ui.text.font.FontFamily', + 'import androidx.compose.ui.text.font.FontWeight', + 'import androidx.compose.ui.text.style.LineBreak', + 'import androidx.compose.ui.text.style.LineHeightStyle', + 'import androidx.compose.ui.unit.sp', + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + '@Stable', + 'class TangemTypography3 internal constructor(fontFamily: FontFamily) {', + outerProps.join('\n'), + '', + innerClasses.join('\n\n'), + '}', + '', + ].join('\n'); + }, +}); + +function mapFontWeight(value) { + const num = parseInt(value, 10); + if (!isNaN(num)) { + if (num <= 400) return 'FontWeight.Normal'; + if (num <= 500) return 'FontWeight.Medium'; + if (num <= 600) return 'FontWeight.SemiBold'; + return 'FontWeight.Bold'; + } + const lower = String(value).toLowerCase(); + if (lower.includes('semibold') || lower.includes('semi bold')) return 'FontWeight.SemiBold'; + if (lower.includes('bold')) return 'FontWeight.Bold'; + if (lower.includes('medium')) return 'FontWeight.Medium'; + return 'FontWeight.Normal'; +} + +/** + * Kotlin format for TangemColors3 class definition. + * Generates the @Stable class hierarchy with mutableStateOf + update() pattern. + * Structure is derived from light theme codeSyntax.Android fields. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-colors3-class', + format: ({ dictionary }) => { + const colorTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && !isSourceOnlyToken(t), + ); + + colors3StructureTree = buildClassStructureTree(colorTokens); + resolveClassTreeConflicts(colors3StructureTree); + + const classLines = renderStableClass('TangemColors3', colors3StructureTree, 0); + + return [ + FILE_SUPPRESS, + '', + `package ${PACKAGE}`, + '', + 'import androidx.compose.runtime.Stable', + 'import androidx.compose.runtime.getValue', + 'import androidx.compose.runtime.mutableStateOf', + 'import androidx.compose.runtime.setValue', + 'import androidx.compose.ui.graphics.Color', + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + ...classLines, + '', + ].join('\n'); + }, +}); + +/** + * Kotlin format for TangemColors3 light/dark factory functions. + * Generates lightColors3() / darkColors3() functions referencing TangemColorPalette. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-colors3-factory', + format: ({ dictionary, options }) => { + const themeName = options.themeName; + const funcName = `${themeName.toLowerCase()}Colors3`; + + // Ensure tree is built (should already be set by class format in Light build) + if (!colors3StructureTree) { + const colorTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && !isSourceOnlyToken(t), + ); + colors3StructureTree = buildClassStructureTree(colorTokens); + resolveClassTreeConflicts(colors3StructureTree); + } + + // Build value map: jsonPath → Kotlin palette reference or Color literal + const colorTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && !isSourceOnlyToken(t), + ); + const valueMap = new Map(); + for (const token of colorTokens) { + const jsonPath = token.path.join('.'); + valueMap.set(jsonPath, paletteRefOrColor(token)); + } + + const bodyLines = renderFactoryContent('TangemColors3', colors3StructureTree, valueMap, 2); + + return [ + FILE_SUPPRESS, + '', + `package ${PACKAGE}`, + '', + 'import androidx.compose.ui.graphics.Color', + '', + '/**', + ` * Auto-generated from design tokens. Do not edit manually.`, + ` * Theme: ${themeName}`, + ' */', + `internal fun ${funcName}() =`, + ' TangemColors3(', + ...bodyLines, + ' )', + '', + ].join('\n'); + }, +}); + +// ── Build ────────────────────────────────────────────────────────────────────── + +const composePlatformTransforms = [ + ...getTransforms({ platform: 'css' }), + 'name/camel', +]; + +// Build color tokens per theme (light/dark) +for (const [themeName, { sets }] of Object.entries(themeBuilds)) { + console.log(`\nBuilding ${themeName} color tokens...`); + + const colorFilter = token => token.$type === 'color' && !isSourceOnlyToken(token); + + const files = []; + + // Only light build generates the class definition (canonical codeSyntax structure) + if (themeName === 'Light') { + files.push({ + destination: 'TangemColors3.kt', + format: 'kotlin/compose-colors3-class', + filter: colorFilter, + }); + } + + // Both themes generate factory functions + files.push({ + destination: `TangemColors3${themeName}.kt`, + format: 'kotlin/compose-colors3-factory', + options: { themeName }, + filter: colorFilter, + }); + + const sd = new StyleDictionary({ + source: sets.map(s => path.join(tokensDir, `${s}.json`)), + preprocessors: ['tokens-studio'], + usesDtcg: true, + log: { warnings: 'disabled', errors: { brokenReferences: 'console' } }, + platforms: { + compose: { + transforms: composePlatformTransforms, + buildPath: outputDir + '/', + files, + }, + }, + }); + + await sd.buildAllPlatforms(); + if (themeName === 'Light') console.log(' ✓ TangemColors3.kt'); + console.log(` ✓ TangemColors3${themeName}.kt`); +} + +// Build palette tokens +console.log('\nBuilding palette tokens...'); + +const paletteSd = new StyleDictionary({ + source: [...coreSets, 'semantic/size/opacity'].map(s => path.join(tokensDir, `${s}.json`)), + preprocessors: ['tokens-studio'], + usesDtcg: true, + log: { warnings: 'disabled', errors: { brokenReferences: 'console' } }, + platforms: { + compose: { + transforms: composePlatformTransforms, + buildPath: outputDir + '/', + files: [ + { + destination: 'TangemColorPalette.kt', + format: 'kotlin/compose-palette', + filter: token => token.$type === 'color' && token.path[0] === 'palette', + }, + ], + }, + }, +}); + +await paletteSd.buildAllPlatforms(); +console.log(' ✓ TangemColorPalette.kt'); + +// Build theme-independent tokens (dimensions, typography) +console.log('\nBuilding dimension and typography tokens...'); + +const sd = new StyleDictionary({ + source: sharedBuildSets.map(s => path.join(tokensDir, `${s}.json`)), + preprocessors: ['tokens-studio'], + usesDtcg: true, + log: { warnings: 'disabled', errors: { brokenReferences: 'console' } }, + platforms: { + compose: { + transforms: composePlatformTransforms, + buildPath: outputDir + '/', + files: [ + { + destination: 'TangemDimens3.kt', + format: 'kotlin/compose-dimens3', + }, + { + destination: 'TangemTypography3.kt', + format: 'kotlin/compose-typography3', + filter: token => token.$type === 'typography' && !isSourceOnlyToken(token), + }, + ], + }, + }, +}); + +await sd.buildAllPlatforms(); +console.log(' ✓ TangemDimens3.kt'); +console.log(' ✓ TangemTypography3.kt'); + +// ── Write source hash ───────────────────────────────────────────────────────── +// Hash all token JSON files so Gradle can verify generated code matches ds-tokens. +function computeTokensHash() { + const files = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.json')) files.push(full); + } + } + walk(tokensDir); + // Sort by relative path with forward slashes to match Gradle's invariantSeparatorsPath sorting + files.sort((a, b) => { + const ra = path.relative(tokensDir, a).split(path.sep).join('/'); + const rb = path.relative(tokensDir, b).split(path.sep).join('/'); + return ra.localeCompare(rb); + }); + + const hash = crypto.createHash('sha256'); + for (const file of files) { + hash.update(path.relative(tokensDir, file).split(path.sep).join('/')); + hash.update('\0'); + hash.update(fs.readFileSync(file)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +const tokensHash = computeTokensHash(); +fs.writeFileSync(path.join(outputDir, '.tokens-hash'), tokensHash + '\n'); +console.log(` ✓ .tokens-hash (${tokensHash.substring(0, 12)}…)`); + +console.log(`\nDone! Output: ${outputDir}`); diff --git a/core/ui/token-gen/package-lock.json b/core/ui/token-gen/package-lock.json new file mode 100644 index 0000000000..2506a02e51 --- /dev/null +++ b/core/ui/token-gen/package-lock.json @@ -0,0 +1,1749 @@ +{ + "name": "tangem-token-gen", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tangem-token-gen", + "version": "1.0.0", + "devDependencies": { + "@tokens-studio/sd-transforms": "^2.0.3", + "style-dictionary": "^5.4.0" + } + }, + "node_modules/@bundled-es-modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==", + "dev": true, + "license": "ISC", + "dependencies": { + "deepmerge": "^4.3.1" + } + }, + "node_modules/@bundled-es-modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-x9nR2e1pt8LF0yLPC6yz/aUoiN7qJJwZ1znLxIXCxGyH+8BI+yO/sklBdn1+QbUyWXQBM+CjfZz3IhqtgIoDVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "events": "^3.3.0", + "glob": "^13.0.6", + "path": "^0.12.7", + "stream": "^0.0.3", + "string_decoder": "^1.3.0", + "url": "^0.11.4" + } + }, + "node_modules/@bundled-es-modules/memfs": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/memfs/-/memfs-4.17.0.tgz", + "integrity": "sha512-ykdrkEmQr9BV804yd37ikXfNnvxrwYfY9Z2/EtMHFEFadEjsQXJ1zL9bVZrKNLDtm91UdUOEHso6Aweg93K6xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "assert": "^2.1.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "memfs": "^4.17.0", + "path": "^0.12.7", + "stream": "^0.0.3", + "util": "^0.12.5" + } + }, + "node_modules/@bundled-es-modules/postcss-calc-ast-parser": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.6.tgz", + "integrity": "sha512-y65TM5zF+uaxo9OeekJ3rxwTINlQvrkbZLogYvQYVoLtxm4xEiHfZ7e/MyiWbStYyWZVZkVqsaVU6F4SUK5XUA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-calc-ast-parser": "^0.1.4" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", + "integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", + "integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", + "integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", + "integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", + "integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", + "integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", + "integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.1", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", + "integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@tokens-studio/sd-transforms": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@tokens-studio/sd-transforms/-/sd-transforms-2.0.3.tgz", + "integrity": "sha512-PyrmRb7FuJBHzsbuWNk/O06hJbaZ+RL7chmK7PRbEptaSruhANai7Kxja5CiYnTcxOj373uRMDPxoFwCHaVpvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/deepmerge": "^4.3.1", + "@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6", + "@tokens-studio/types": "^0.5.1", + "colorjs.io": "^0.5.2", + "expr-eval-fork": "^3.0.1", + "is-mergeable-object": "^1.1.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "style-dictionary": "^5.0.0" + } + }, + "node_modules/@tokens-studio/types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.5.2.tgz", + "integrity": "sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/component-emitter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz", + "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expr-eval-fork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.3.tgz", + "integrity": "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mergeable-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-mergeable-object/-/is-mergeable-object-1.1.1.tgz", + "integrity": "sha512-CPduJfuGg8h8vW74WOxHtHmtQutyQBzR+3MjQ6iDHIYdbOnm1YC7jv43SqCoU8OPGTJD4nibmiryA4kmogbGrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memfs": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", + "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-to-fsa": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-unified": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/path-unified/-/path-unified-0.2.0.tgz", + "integrity": "sha512-MNKqvrKbbbb5p7XHXV6ZAsf/1f/yJQa13S/fcX0uua8ew58Tgc6jXV+16JyAbnR/clgCH+euKDxrF2STxMHdrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/path/node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss-calc-ast-parser": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.4.tgz", + "integrity": "sha512-CebpbHc96zgFjGgdQ6BqBy6XIUgRx1xXWCAAk6oke02RZ5nxwo9KQejTg8y7uYEeI9kv8jKQPYjoe6REsY23vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^3.3.1" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.3.tgz", + "integrity": "sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^2.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/style-dictionary": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-5.4.0.tgz", + "integrity": "sha512-6BzO0DV19t6KUEXYfvHJ73d3y8bBDcd0wNLfoZRX817obJ8YX5Vev8Xh3+k9601tHE8qRJ/586iLt0byuY2THw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bundled-es-modules/deepmerge": "^4.3.1", + "@bundled-es-modules/glob": "^13.0.6", + "@bundled-es-modules/memfs": "^4.17.0", + "@zip.js/zip.js": "^2.7.44", + "chalk": "^5.3.0", + "change-case": "^5.3.0", + "colorjs.io": "^0.5.2", + "commander": "^12.1.0", + "is-plain-obj": "^4.1.0", + "json5": "^2.2.2", + "path-unified": "^0.2.0", + "prettier": "^3.3.3", + "tinycolor2": "^1.6.0" + }, + "bin": { + "style-dictionary": "bin/style-dictionary.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + } + } +} diff --git a/core/ui/token-gen/package.json b/core/ui/token-gen/package.json new file mode 100644 index 0000000000..cfb40a9fc8 --- /dev/null +++ b/core/ui/token-gen/package.json @@ -0,0 +1,13 @@ +{ + "name": "tangem-token-gen", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "node build-tokens.mjs" + }, + "devDependencies": { + "style-dictionary": "^5.4.0", + "@tokens-studio/sd-transforms": "^2.0.3" + } +} diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index 0d59823349..48e632eff4 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -23,10 +23,6 @@ dependencies { implementation(deps.jodatime) // endregion - // region Logging - implementation(deps.kermit) - // endregion - testImplementation(deps.test.coroutine) testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) diff --git a/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt b/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt index 420547d13c..07fc6985b2 100644 --- a/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt +++ b/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt @@ -13,7 +13,7 @@ object SupportedLanguages { const val CHINESE = "zh" const val SPANISH = "es" - val supportedLangugeCodes = listOf( + val supportedLanguageCodes = listOf( ENGLISH, RUSSIAN, GERMAN, @@ -25,10 +25,17 @@ object SupportedLanguages { SPANISH, ) + /** + * Returns the ISO 639-1 code of the device's current language when it belongs to + * [supportedLanguageCodes], otherwise falls back to [ENGLISH]. + * + * Intended for callers that need a plain two-letter language code (e.g. URL path segments + * like `tangem.com/{en|ru}/...`). + */ fun getCurrentSupportedLanguageCode(): String { val locale = Locale.getDefault() - return if (supportedLangugeCodes.contains(locale.language)) { + return if (supportedLanguageCodes.contains(locale.language)) { locale.language } else { ENGLISH diff --git a/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt b/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt deleted file mode 100644 index d24267a527..0000000000 --- a/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.utils - -import java.util.Locale - -@Deprecated("Use TangemBlogUrlBuilder from common module") -object TangemBlogUrlBuilder { - - private const val RU_LOCALE = "ru" - private const val EN_LOCALE = "en" - - private const val TANGEM_MAIN = "https://tangem.com/" - - val FEE_BLOG_LINK: String - get(): String { - val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE - return buildString { - append(TANGEM_MAIN) - append(locale) - append("/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/") - } - } - - const val RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP = "https://tangem.com/en/blog/post/give-revoke-permission/" - - const val YIELD_SUPPLY_HOW_IT_WORKS_URL = "https://tangem.com/en/blog/post/yield-mode" - const val YIELD_SUPPLY_TOS_URL = "https://aave.com/terms-of-service" - const val YIELD_SUPPLY_PRIVACY_URL = "https://aave.com/privacy-policy" -} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt b/core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt new file mode 100644 index 0000000000..4a9f4f9468 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt @@ -0,0 +1,32 @@ +package com.tangem.utils.annotations + +/** + * Marks code that should be removed when the specified feature toggle is cleaned up + * by the `/cleanup-feature-toggles` skill. + * + * @property toggleName the name of the feature toggle (e.g., "GASLESS_APPROVAL_ENABLED") + * @property description optional description of what should be done during cleanup + * +[REDACTED_AUTHOR] + */ +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.TYPE, + AnnotationTarget.EXPRESSION, + AnnotationTarget.FILE, + AnnotationTarget.TYPEALIAS, +) +@Retention(AnnotationRetention.SOURCE) +annotation class RemoveWithToggle( + val toggleName: String, + val description: String = "", +) \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt index 7337a4f114..4ef568d2e6 100644 --- a/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt +++ b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt @@ -1,11 +1,44 @@ package com.tangem.utils.info +/** + * Runtime information about the host application and device. + * + * Consumed by API layers to populate request headers and bodies (see + * `RequestHeader.AppVersionPlatformHeaders` and push-notification registration) and by feature + * code that needs to branch on platform / vendor. + */ interface AppInfoProvider { + + /** Platform identifier, e.g. `"Android"`. */ val platform: String + + /** Human-readable device name in the form `"$MANUFACTURER $MODEL"`, e.g. `"Google Pixel 8"`. */ val device: String + + /** OS release string, e.g. `"14"` on Android 14. Corresponds to `Build.VERSION.RELEASE`. */ val osVersion: String + + /** + * Android API level of the running system, e.g. `34` on Android 14. Corresponds to + * `Build.VERSION.SDK_INT`. Use this (not [osVersion]) when branching by framework capability. + */ + val sdkVersion: Int + + /** Current locale as a BCP 47 language tag (e.g. `"en-US"`, `"zh-CN"`). */ val language: String + + /** IANA time-zone id of the device's current time zone, e.g. `"Europe/Moscow"`, `"UTC"`. */ val timezone: String + + /** User-visible app version string (e.g. `"5.36.4"`), matching `BuildConfig.VERSION_NAME`. */ val appVersion: String + + /** Monotonically-increasing internal build number, matching `BuildConfig.VERSION_CODE`. */ + val appVersionCode: Int + + /** + * `true` if the device manufacturer or brand is Huawei. Used to gate features that depend on + * Google Play Services (HMS-only devices cannot rely on FCM, Play Billing, etc.). + */ val isHuaweiDevice: Boolean } \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/logging/BaseLogger.kt b/core/utils/src/main/java/com/tangem/utils/logging/BaseLogger.kt new file mode 100644 index 0000000000..dca1140ca6 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/logging/BaseLogger.kt @@ -0,0 +1,13 @@ +package com.tangem.utils.logging + +/** + * Common contract for application loggers. + */ +internal interface BaseLogger { + fun v(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun d(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun i(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun w(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun e(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun a(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/logging/LogTagResolver.kt b/core/utils/src/main/java/com/tangem/utils/logging/LogTagResolver.kt new file mode 100644 index 0000000000..0e0228c472 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/logging/LogTagResolver.kt @@ -0,0 +1,35 @@ +package com.tangem.utils.logging + +import java.util.regex.Pattern + +/** + * Resolves a log tag from the call site's class name when the caller didn't supply one + * via [TangemLogger.withTag]. Used by [TangemLogger.write] before dispatching to writers. + */ +internal object LogTagResolver { + + private const val FALLBACK_TAG = "TangemAppLogger" + + private val ANONYMOUS_CLASS_REGEX: Pattern = Pattern.compile("(\\$\\d+)+$") + + private val FQCN_IGNORE = setOf( + LogTagResolver::class.java.name, + TangemLogger::class.java.name, + TangemLogger.TaggedLogger::class.java.name, + // Synthetic class generated for BaseLogger's default-arg trampolines (d$default, etc.). + // Without this, every call that omits default args resolves to BaseLogger.DefaultImpls. + "${BaseLogger::class.java.name}\$DefaultImpls", + ) + + @Suppress("ThrowingExceptionsWithoutMessageOrCause") + fun resolveTag(): String { + val element = Throwable().stackTrace.firstOrNull { it.className !in FQCN_IGNORE } + ?: return FALLBACK_TAG + var tag = element.className.substringAfterLast('.') + val matcher = ANONYMOUS_CLASS_REGEX.matcher(tag) + if (matcher.find()) { + tag = matcher.replaceAll("") + } + return tag + } +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/logging/Severity.kt b/core/utils/src/main/java/com/tangem/utils/logging/Severity.kt new file mode 100644 index 0000000000..52f9e62a20 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/logging/Severity.kt @@ -0,0 +1,10 @@ +package com.tangem.utils.logging + +enum class Severity { + Verbose, + Debug, + Info, + Warn, + Error, + Assert, +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt b/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt index 5bd1fab06f..c7a8aa10ef 100644 --- a/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt +++ b/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt @@ -1,63 +1,173 @@ package com.tangem.utils.logging -import co.touchlab.kermit.Logger +import java.util.concurrent.CopyOnWriteArrayList /** - * Application-level logger that wraps Kermit [Logger] with the same API. - * All modules should use [TangemLogger] instead of importing Kermit directly. + * Application-level logger */ -object TangemLogger { +object TangemLogger : BaseLogger { - fun v(messageString: String, throwable: Throwable? = null) { - Logger.v(messageString, throwable) + private val logWriters = CopyOnWriteArrayList() + + fun setLogWriters(writers: List) { + logWriters.clear() + logWriters.addAll(writers) } - fun d(messageString: String, throwable: Throwable? = null) { - Logger.d(messageString, throwable) + fun addLogWriter(writer: LogWriter) { + logWriters.add(writer) } - fun i(messageString: String, throwable: Throwable? = null) { - Logger.i(messageString, throwable) + override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Verbose, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } - fun w(messageString: String, throwable: Throwable? = null) { - Logger.w(messageString, throwable) + override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Debug, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } - fun e(messageString: String, throwable: Throwable? = null) { - Logger.e(messageString, throwable) + override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Info, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } - fun a(messageString: String, throwable: Throwable? = null) { - Logger.a(messageString, throwable) + override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Warn, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Error, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Assert, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } fun withTag(tag: String): TaggedLogger = TaggedLogger(tag) - class TaggedLogger internal constructor(private val tag: String) { - - fun v(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).v(messageString, throwable) - } - - fun d(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).d(messageString, throwable) - } - - fun i(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).i(messageString, throwable) - } - - fun w(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).w(messageString, throwable) - } - - fun e(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).e(messageString, throwable) - } - - fun a(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).a(messageString, throwable) + private fun write( + severity: Severity, + tag: String?, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + val resolvedTag = tag ?: LogTagResolver.resolveTag() + logWriters.forEach { writer -> + if (writer.isLoggable(severity, resolvedTag)) { + writer.write( + severity = severity, + tag = resolvedTag, + message = message, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } } } + + class TaggedLogger internal constructor(private val tag: String) : BaseLogger { + + override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Verbose, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Debug, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Info, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Warn, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Error, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Assert, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + } + + interface LogWriter { + + fun isLoggable(severity: Severity, tag: String): Boolean = true + + fun write(severity: Severity, tag: String, message: String, throwable: Throwable?, shouldSanitize: Boolean) + } } \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/version/AppVersionProvider.kt b/core/utils/src/main/java/com/tangem/utils/version/AppVersionProvider.kt deleted file mode 100644 index 23e26bebd1..0000000000 --- a/core/utils/src/main/java/com/tangem/utils/version/AppVersionProvider.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.utils.version - -interface AppVersionProvider { - - val versionName: String - - val versionCode: Int -} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/SupportedLanguagesTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/SupportedLanguagesTest.kt new file mode 100644 index 0000000000..3e3729e3f8 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/SupportedLanguagesTest.kt @@ -0,0 +1,94 @@ +package com.tangem.utils + +import com.google.common.truth.Truth +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.Locale + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SupportedLanguagesTest { + + private lateinit var originalLocale: Locale + + @BeforeEach + fun setUp() { + originalLocale = Locale.getDefault() + } + + @AfterEach + fun tearDown() { + Locale.setDefault(originalLocale) + } + + @Test + fun `getCurrentSupportedLanguageCode returns primary language when locale is supported`() { + // Arrange + Locale.setDefault(Locale("en", "US")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo("en") + } + + @Test + fun `getCurrentSupportedLanguageCode drops region for supported language`() { + // Arrange + Locale.setDefault(Locale("zh", "CN")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo("zh") + } + + @Test + fun `getCurrentSupportedLanguageCode returns ENGLISH when locale is not supported`() { + // Arrange — pt (Portuguese) is not in supportedLanguageCodes + Locale.setDefault(Locale("pt", "BR")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo(SupportedLanguages.ENGLISH) + } + + @Test + fun `getCurrentSupportedLanguageCode returns ENGLISH for empty language`() { + // Arrange + Locale.setDefault(Locale("", "")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo(SupportedLanguages.ENGLISH) + } + + @Test + fun `getCurrentSupportedLanguageCode supports every code in supportedLanguageCodes`() { + SupportedLanguages.supportedLanguageCodes.forEach { code -> + // Arrange + Locale.setDefault(Locale(code)) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo(code) + } + } + + @Test + fun `supportedLanguageCodes contains the expected nine ISO 639-1 codes`() { + // Assert + Truth.assertThat(SupportedLanguages.supportedLanguageCodes) + .containsExactly("en", "ru", "de", "fr", "it", "ja", "uk", "zh", "es") + .inOrder() + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/logging/LogTagResolverTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/logging/LogTagResolverTest.kt new file mode 100644 index 0000000000..194944c929 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/logging/LogTagResolverTest.kt @@ -0,0 +1,100 @@ +package com.tangem.utils.logging + +import com.google.common.truth.Truth +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class LogTagResolverTest { + + @AfterEach + fun tearDown() { + // Clean up shared TangemLogger state used in some cases + TangemLogger.setLogWriters(emptyList()) + } + + @Test + fun `resolveTag returns the simple class name of the direct caller`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).isEqualTo("LogTagResolverTest") + } + + @Test + fun `resolveTag does not include package qualifier`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).doesNotContain(".") + } + + @Test + fun `resolveTag never returns its own class name`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).isNotEqualTo("LogTagResolver") + } + + @Test + fun `resolveTag skips TangemLogger frames when invoked through it`() { + // Arrange + var capturedTag: String? = null + val writer = object : TangemLogger.LogWriter { + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + capturedTag = tag + } + } + TangemLogger.setLogWriters(listOf(writer)) + + // Act + TangemLogger.d("via TangemLogger") + + // Assert — TangemLogger and LogTagResolver are filtered, leaving the test class + Truth.assertThat(capturedTag).isEqualTo("LogTagResolverTest") + } + + @Test + fun `resolveTag is bypassed by TaggedLogger when an explicit tag is supplied`() { + // Arrange + var capturedTag: String? = null + val writer = object : TangemLogger.LogWriter { + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + capturedTag = tag + } + } + TangemLogger.setLogWriters(listOf(writer)) + + // Act + TangemLogger.withTag("ExplicitTag").d("hi") + + // Assert + Truth.assertThat(capturedTag).isEqualTo("ExplicitTag") + } + + @Test + fun `resolveTag returns a non-empty string`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).isNotEmpty() + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/logging/TangemLoggerTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/logging/TangemLoggerTest.kt new file mode 100644 index 0000000000..a1c42eae34 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/logging/TangemLoggerTest.kt @@ -0,0 +1,311 @@ +package com.tangem.utils.logging + +import com.google.common.truth.Truth +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TangemLoggerTest { + + private lateinit var writer: TangemLogger.LogWriter + + @BeforeEach + fun setUp() { + writer = mockk(relaxed = true) + every { writer.isLoggable(any(), any()) } returns true + TangemLogger.setLogWriters(listOf(writer)) + } + + @AfterEach + fun tearDown() { + // Reset singleton state to avoid cross-test pollution + TangemLogger.setLogWriters(emptyList()) + } + + // region Severity dispatch + + @Test + fun `v dispatches Verbose severity to writer`() { + // Act + TangemLogger.v("verbose message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Verbose, any(), "verbose message", null, true) + } + } + + @Test + fun `d dispatches Debug severity to writer`() { + // Act + TangemLogger.d("debug message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Debug, any(), "debug message", null, true) + } + } + + @Test + fun `i dispatches Info severity to writer`() { + // Act + TangemLogger.i("info message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Info, any(), "info message", null, true) + } + } + + @Test + fun `w dispatches Warn severity to writer`() { + // Act + TangemLogger.w("warn message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Warn, any(), "warn message", null, true) + } + } + + @Test + fun `e dispatches Error severity to writer`() { + // Act + TangemLogger.e("error message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Error, any(), "error message", null, true) + } + } + + @Test + fun `a dispatches Assert severity to writer`() { + // Act + TangemLogger.a("assert message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Assert, any(), "assert message", null, true) + } + } + + // endregion + + // region Throwable & shouldSanitize propagation + + @Test + fun `throwable parameter is forwarded to writer`() { + // Arrange + val throwable = IllegalStateException("boom") + + // Act + TangemLogger.e("error", throwable) + + // Assert + verify(exactly = 1) { + writer.write(Severity.Error, any(), "error", throwable, true) + } + } + + @Test + fun `shouldSanitize flag is forwarded to writer`() { + // Act + TangemLogger.w("not checked", shouldSanitize = false) + + // Assert + verify(exactly = 1) { + writer.write(Severity.Warn, any(), "not checked", null, false) + } + } + + // endregion + + // region setLogWriters / addLogWriter + + @Test + fun `setLogWriters replaces previously registered writers`() { + // Arrange + val previous: TangemLogger.LogWriter = mockk(relaxed = true) + every { previous.isLoggable(any(), any()) } returns true + val replacement: TangemLogger.LogWriter = mockk(relaxed = true) + every { replacement.isLoggable(any(), any()) } returns true + + TangemLogger.setLogWriters(listOf(previous)) + TangemLogger.setLogWriters(listOf(replacement)) + + // Act + TangemLogger.i("after replace") + + // Assert + verify(exactly = 0) { previous.write(any(), any(), any(), any(), any()) } + verify(exactly = 1) { + replacement.write(Severity.Info, any(), "after replace", null, true) + } + } + + @Test + fun `addLogWriter appends without removing existing writers`() { + // Arrange + val first: TangemLogger.LogWriter = mockk(relaxed = true) + every { first.isLoggable(any(), any()) } returns true + val second: TangemLogger.LogWriter = mockk(relaxed = true) + every { second.isLoggable(any(), any()) } returns true + + TangemLogger.setLogWriters(listOf(first)) + TangemLogger.addLogWriter(second) + + // Act + TangemLogger.d("broadcast") + + // Assert + verify(exactly = 1) { first.write(Severity.Debug, any(), "broadcast", null, true) } + verify(exactly = 1) { second.write(Severity.Debug, any(), "broadcast", null, true) } + } + + @Test + fun `setLogWriters with empty list silences all output`() { + // Arrange + TangemLogger.setLogWriters(emptyList()) + + // Act + TangemLogger.i("nobody listening") + + // Assert + verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) } + } + + // endregion + + // region isLoggable filtering + + @Test + fun `write is skipped when isLoggable returns false`() { + // Arrange + every { writer.isLoggable(any(), any()) } returns false + + // Act + TangemLogger.w("filtered out") + + // Assert + verify(exactly = 1) { writer.isLoggable(Severity.Warn, any()) } + verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) } + } + + @Test + fun `each writer is filtered independently by its own isLoggable`() { + // Arrange + val accepting: TangemLogger.LogWriter = mockk(relaxed = true) + every { accepting.isLoggable(any(), any()) } returns true + val rejecting: TangemLogger.LogWriter = mockk(relaxed = true) + every { rejecting.isLoggable(any(), any()) } returns false + + TangemLogger.setLogWriters(listOf(accepting, rejecting)) + + // Act + TangemLogger.i("partial") + + // Assert + verify(exactly = 1) { accepting.write(Severity.Info, any(), "partial", null, true) } + verify(exactly = 0) { rejecting.write(any(), any(), any(), any(), any()) } + } + + @Test + fun `LogWriter isLoggable defaults to true`() { + // Arrange + val realWriter = object : TangemLogger.LogWriter { + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) = Unit + } + + // Act + Assert + Severity.entries.forEach { severity -> + Truth.assertThat(realWriter.isLoggable(severity, "anyTag")).isTrue() + } + } + + // endregion + + // region Tag resolution + + @Test + fun `resolved tag falls back to caller class name when no tag is provided`() { + // Act + TangemLogger.d("no tag") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Debug, "TangemLoggerTest", "no tag", null, true) + } + } + + @Test + fun `withTag returns a TaggedLogger that uses the supplied tag`() { + // Arrange + val tagged = TangemLogger.withTag("MyFeature") + + // Act + tagged.i("hello") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Info, "MyFeature", "hello", null, true) + } + } + + // endregion + + // region TaggedLogger + + @Test + fun `TaggedLogger dispatches each severity with its tag, throwable and shouldSanitize flag`() { + // Arrange + val tagged = TangemLogger.withTag("Tag") + val throwable = RuntimeException("oops") + + // Act + tagged.v("v") + tagged.d("d") + tagged.i("i") + tagged.w("w") + tagged.e("e", throwable) + tagged.a("a", shouldSanitize = false) + + // Assert + verifyOrder { + writer.write(Severity.Verbose, "Tag", "v", null, true) + writer.write(Severity.Debug, "Tag", "d", null, true) + writer.write(Severity.Info, "Tag", "i", null, true) + writer.write(Severity.Warn, "Tag", "w", null, true) + writer.write(Severity.Error, "Tag", "e", throwable, true) + writer.write(Severity.Assert, "Tag", "a", null, false) + } + } + + @Test + fun `TaggedLogger respects writer isLoggable filtering`() { + // Arrange + every { writer.isLoggable(any(), any()) } returns false + val tagged = TangemLogger.withTag("Filtered") + + // Act + tagged.e("ignored") + + // Assert + verify(exactly = 1) { writer.isLoggable(Severity.Error, "Filtered") } + verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) } + } + + // endregion +} \ No newline at end of file diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 9f3fa0d8a4..9e1cd33fea 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { api(projects.domain.visa) // endregion - implementation(projects.features.tangempay.details.api) // Remove after TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED - // region Project - Data implementation(projects.data.common) // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt index 3d35e7327c..312e189d67 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt @@ -1,6 +1,7 @@ package com.tangem.data.account.converter import arrow.core.getOrElse +import arrow.core.right import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.CryptoPortfolioIcon @@ -8,7 +9,10 @@ import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { - return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse { + return when { + startsWith(AccountId.PaymentAccountIdPrefix) -> AccountId.forPaymentAccount(userWalletId).right() + else -> AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId) + }.getOrElse { error("Unable to create AccountId from value: $this. Cause: $it") } } diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index b2ba244666..668808ef2a 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,14 +1,9 @@ package com.tangem.data.account.di import android.content.Context -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile -import com.squareup.moshi.Moshi import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher -import com.tangem.data.account.repository.AccountsExpandedDTO import com.tangem.data.account.repository.DefaultAccountsCRUDRepository -import com.tangem.data.account.repository.DefaultAccountsExpandedRepository import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration @@ -17,14 +12,9 @@ import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.datasource.local.datastore.RuntimeStateStore -import com.tangem.datasource.utils.MoshiDataStoreSerializer -import com.tangem.datasource.utils.mapWithStringKeyTypes -import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.repository.AccountsCRUDRepository -import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -63,28 +53,6 @@ internal object AccountDataModule { ) } - @Provides - @Singleton - fun provideAccountsExpandedRepository( - @NetworkMoshi moshi: Moshi, - @ApplicationContext context: Context, - appScope: AppCoroutineScope, - ): AccountsExpandedRepository { - val store = DataStoreFactory.create>>( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = mapWithStringKeyTypes(valueTypes = setTypes()), - defaultValue = emptyMap(), - ), - produceFile = { context.dataStoreFile(fileName = "account_expanded_store") }, - scope = appScope, - ) - - return DefaultAccountsExpandedRepository( - store = store, - ) - } - @Provides @Singleton fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountUtilsModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountUtilsModule.kt new file mode 100644 index 0000000000..5a1e61c2bf --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountUtilsModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.account.di + +import com.tangem.data.account.repository.DefaultAccountsExpandedRepository +import com.tangem.domain.account.repository.AccountsExpandedRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AccountUtilsModule { + + @Binds + fun provideAccountsExpandedRepositoryFactory( + factory: DefaultAccountsExpandedRepository.Factory, + ): AccountsExpandedRepository.Factory +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt index 28fd5fd6be..fcad8c9615 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -11,7 +11,6 @@ import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted @@ -36,7 +35,6 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @Assisted val params: SingleAccountListProducer.Params, override val flowProducerTools: FlowProducerTools, private val walletAccountListFlowFactory: WalletAccountListFlowFactory, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val userWalletsListRepository: UserWalletsListRepository, private val dispatchers: CoroutineDispatcherProvider, ) : SingleAccountListProducer { @@ -45,16 +43,6 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow { - val accountListFlow: Flow = if (tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled) { - combineWithPaymentAccount() - } else { - walletAccountListFlowFactory.create(userWalletId = params.userWalletId) - } - - return accountListFlow.flowOn(dispatchers.default) - } - - private fun combineWithPaymentAccount(): Flow { return walletAccountListFlowFactory.create(params.userWalletId) .map { accountList -> val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId) @@ -66,6 +54,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( accountList } } + .flowOn(dispatchers.default) } private fun UserWallet.isPaymentAccountSupported(): Boolean = when (this) { diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt index e5232c0dc9..2bbaad3697 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt @@ -1,17 +1,28 @@ package com.tangem.data.account.repository +import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi import com.tangem.data.account.converter.toAccountId +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.models.AccountExpandedState import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.AppCoroutineScope +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import javax.inject.Inject -internal class DefaultAccountsExpandedRepository( +internal class DefaultAccountsExpandedRepository constructor( private val store: DataStore>>, ) : AccountsExpandedRepository { @@ -41,7 +52,7 @@ internal class DefaultAccountsExpandedRepository( } override suspend fun clearStore() { - store.updateData { emptyMap() } + store.updateData { map -> map.mapValues { emptySet() } } } override suspend fun update(accountState: AccountExpandedState) { @@ -59,6 +70,28 @@ internal class DefaultAccountsExpandedRepository( map.plus(walletId.stringValue to updatedSet) } } + + internal class Factory @Inject constructor( + @NetworkMoshi private val moshi: Moshi, + @ApplicationContext private val context: Context, + private val appScope: AppCoroutineScope, + ) : AccountsExpandedRepository.Factory { + override fun create(storeFileName: String): DefaultAccountsExpandedRepository { + val store = DataStoreFactory.create>>( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(valueTypes = setTypes()), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = storeFileName) }, + scope = appScope, + ) + + return DefaultAccountsExpandedRepository( + store = store, + ) + } + } } @JsonClass(generateAdapter = true) diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt index 4837e4f2a6..d4138d111c 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -8,7 +8,7 @@ import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.tangempay.TangemPayFeatureToggles +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -31,21 +31,22 @@ class DefaultSingleAccountListProducerTest { private val userWalletId = UserWalletId("011") private val flowProducerTools: FlowProducerTools = mockk() - private val tangemPayFeatureToggles = mockk { - every { this@mockk.isTangemPayAccountsRefactorEnabled } returns false + private val userWallet = mockk { + every { walletId } returns userWalletId + every { hotWalletId } returns mockk { + every { authType } returns HotWalletId.AuthType.NoPassword + } } - private val userWalletsListRepository = mockk() - private val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId + private val userWalletsListRepository = mockk { + every { userWallets } returns MutableStateFlow?>(value = listOf(userWallet)) } private val producer = DefaultSingleAccountListProducer( params = SingleAccountListProducer.Params(userWalletId = userWalletId), walletAccountListFlowFactory = walletAccountListFlowFactory, - dispatchers = TestingCoroutineDispatcherProvider(), flowProducerTools = flowProducerTools, - tangemPayFeatureToggles = tangemPayFeatureToggles, userWalletsListRepository = userWalletsListRepository, + dispatchers = TestingCoroutineDispatcherProvider(), ) @AfterEach @@ -56,8 +57,6 @@ class DefaultSingleAccountListProducerTest { @Test fun produce() = runTest { // Arrange - MutableStateFlow(listOf(userWallet)) - val accountList = AccountList.empty(userWalletId) every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) diff --git a/data/tokensync/build.gradle.kts b/data/assetsdiscovery/build.gradle.kts similarity index 78% rename from data/tokensync/build.gradle.kts rename to data/assetsdiscovery/build.gradle.kts index 973df2ab9a..d043536d23 100644 --- a/data/tokensync/build.gradle.kts +++ b/data/assetsdiscovery/build.gradle.kts @@ -7,22 +7,25 @@ plugins { } android { - namespace = "com.tangem.data.tokensync" + namespace = "com.tangem.data.assetsdiscovery" } dependencies { - api(projects.domain.tokensync) + api(projects.domain.assetsdiscovery) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) implementation(projects.domain.models) implementation(projects.domain.walletManager) implementation(projects.domain.wallets) implementation(projects.data.common) + implementation(projects.data.walletManager) implementation(projects.libs.blockchainSdk) + implementation(projects.libs.tangemSdkApi) implementation(projects.core.datasource) implementation(projects.core.utils) implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) implementation(deps.androidx.datastore) diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/DefaultAssetsDiscoveryFacade.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/DefaultAssetsDiscoveryFacade.kt new file mode 100644 index 0000000000..412cb02e9b --- /dev/null +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/DefaultAssetsDiscoveryFacade.kt @@ -0,0 +1,86 @@ +package com.tangem.data.assetsdiscovery + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.address.AddressType +import com.tangem.blockchainsdk.BlockchainSDKFactory +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.data.walletmanager.extensions.makePublicKey +import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.config.curvesConfig +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class DefaultAssetsDiscoveryFacade @Inject constructor( + private val blockchainSDKFactory: BlockchainSDKFactory, + private val userWalletsListRepository: UserWalletsListRepository, + private val dispatchers: CoroutineDispatcherProvider, +) : AssetsDiscoveryFacade { + + override suspend fun getAssetsDiscoveryService( + userWalletId: UserWalletId, + network: Network, + ): AssetsDiscoveryFacade.AssetsDiscoveryServiceInfo? = withContext(dispatchers.io) { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + if (userWallet !is UserWallet.Hot) return@withContext null + + val assetsDiscoveryServiceFactory = blockchainSDKFactory.getAssetsDiscoveryServiceFactorySync() + ?: return@withContext null + + val blockchain = network.toBlockchain() + val address = makeAddress(userWallet, blockchain, network.derivationPath.value) + ?: return@withContext null + + AssetsDiscoveryFacade.AssetsDiscoveryServiceInfo( + address = address, + service = assetsDiscoveryServiceFactory.create(blockchain), + ) + } + + private fun makeAddress(hotWallet: UserWallet.Hot, blockchain: Blockchain, derivationPath: String?): String? { + val curve = hotWallet.curvesConfig.primaryCurve(blockchain) + val selectedWallet = hotWallet.wallets.orEmpty().firstOrNull { it.curve == curve } + ?: return null + + val path = derivationPath?.let { DerivationPath(rawPath = it) } + + val publicKey = if (path != null) { + makePublicKey( + seedKey = selectedWallet.publicKey, + blockchain = blockchain, + derivationPath = path, + derivedWalletKeys = selectedWallet.derivedKeys, + isWallet2 = true, + ) ?: return null + } else { + null + } + + return try { + val addresses = if (publicKey != null) { + blockchain.makeAddresses( + walletPublicKey = publicKey.blockchainKey, + pairPublicKey = null, + curve = selectedWallet.curve, + ) + } else { + blockchain.makeAddresses( + walletPublicKey = selectedWallet.publicKey, + pairPublicKey = null, + curve = selectedWallet.curve, + ) + } + addresses.find { it.type == AddressType.Default }?.value + } catch (e: Throwable) { + TangemLogger.w("Failed to derive address for $blockchain", e) + null + } + } +} \ No newline at end of file diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/di/AssetsDiscoveryDataModule.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/di/AssetsDiscoveryDataModule.kt new file mode 100644 index 0000000000..ac451f953c --- /dev/null +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/di/AssetsDiscoveryDataModule.kt @@ -0,0 +1,61 @@ +package com.tangem.data.assetsdiscovery.di + +import com.tangem.blockchainsdk.BlockchainSDKFactory +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.assetsdiscovery.DefaultAssetsDiscoveryFacade +import com.tangem.data.assetsdiscovery.repository.DefaultAssetsDiscoveryRepository +import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStoreFactory +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.network.NetworkFactory +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AssetsDiscoveryDataModule { + + @Provides + @Singleton + fun provideAssetsDiscoveryFacade( + blockchainSDKFactory: BlockchainSDKFactory, + userWalletsListRepository: UserWalletsListRepository, + dispatchers: CoroutineDispatcherProvider, + ): AssetsDiscoveryFacade = DefaultAssetsDiscoveryFacade( + blockchainSDKFactory = blockchainSDKFactory, + userWalletsListRepository = userWalletsListRepository, + dispatchers = dispatchers, + ) + + @Provides + @Singleton + fun provideAssetsDiscoveryRepository( + assetsDiscoveryFacade: AssetsDiscoveryFacade, + tangemTechApi: TangemTechApi, + userWalletsListRepository: UserWalletsListRepository, + networkFactory: NetworkFactory, + appPreferencesStore: AppPreferencesStore, + assetsDiscoveryStoreFactory: AssetsDiscoveryStoreFactory, + responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + dispatchers: CoroutineDispatcherProvider, + excludedBlockchains: ExcludedBlockchains, + ): AssetsDiscoveryRepository = DefaultAssetsDiscoveryRepository( + assetsDiscoveryFacade = assetsDiscoveryFacade, + tangemTechApi = tangemTechApi, + userWalletsListRepository = userWalletsListRepository, + networkFactory = networkFactory, + appPreferencesStore = appPreferencesStore, + assetsDiscoveryStoreFactory = assetsDiscoveryStoreFactory, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + dispatchers = dispatchers, + excludedBlockchains = excludedBlockchains, + ) +} \ No newline at end of file diff --git a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt similarity index 70% rename from data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt index ed5dccaddf..1f05ac7237 100644 --- a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt @@ -1,12 +1,12 @@ -package com.tangem.data.tokensync.repository +package com.tangem.data.assetsdiscovery.repository +import com.tangem.blockchain.assetsdiscovery.models.DiscoveredAsset import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.tokenbalance.models.TokenBalance import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStore +import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStoreFactory import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.common.network.NetworkFactory -import com.tangem.data.tokensync.store.TokenSyncStore -import com.tangem.data.tokensync.store.TokenSyncStoreFactory import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.CoinsResponse @@ -14,15 +14,15 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async @@ -30,42 +30,39 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap @Suppress("LongParameterList") -internal class DefaultTokenSyncRepository( - private val walletManagersFacade: WalletManagersFacade, +internal class DefaultAssetsDiscoveryRepository( + private val assetsDiscoveryFacade: AssetsDiscoveryFacade, private val tangemTechApi: TangemTechApi, private val userWalletsListRepository: UserWalletsListRepository, private val networkFactory: NetworkFactory, private val appPreferencesStore: AppPreferencesStore, - private val tokenSyncStoreFactory: TokenSyncStoreFactory, + private val assetsDiscoveryStoreFactory: AssetsDiscoveryStoreFactory, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, private val dispatchers: CoroutineDispatcherProvider, private val excludedBlockchains: ExcludedBlockchains, -) : TokenSyncRepository { +) : AssetsDiscoveryRepository { - private val semaphore = Semaphore(MAX_CONCURRENT_REQUESTS) - private val progressStates = ConcurrentHashMap>() + private val progressStates = ConcurrentHashMap>() - override fun observeSyncProgress(userWalletId: UserWalletId): Flow { + override fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow { return getProgressFlow(userWalletId) } override fun acknowledgeCompletion(userWalletId: UserWalletId) { val key = userWalletId.stringValue val stateFlow = progressStates[key] ?: return - stateFlow.value = TokenSyncProgress.Idle + stateFlow.value = AssetsDiscoveryProgress.Idle progressStates.remove(key, stateFlow) } override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List { - val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId) - val storedTokens = tokenSyncStore.get() + val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId) + val storedTokens = assetsDiscoveryStore.get() if (storedTokens.isEmpty()) return emptyList() val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) @@ -77,34 +74,34 @@ internal class DefaultTokenSyncRepository( } override suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) { - val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId) - tokenSyncStore.clear() + val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId) + assetsDiscoveryStore.clear() } override suspend fun clearPendingFlag(userWalletId: UserWalletId) { setPendingFlag(userWalletId, value = false) } - override suspend fun getPendingSyncWalletIds(): List { + override suspend fun getPendingDiscoveryWalletIds(): List { val pendingMap = appPreferencesStore - .getObjectMapSync(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY) + .getObjectMapSync(PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY) return pendingMap .filter { it.value } .map { UserWalletId(it.key) } } - override suspend fun runSync(userWalletId: UserWalletId) { + override suspend fun runDiscovery(userWalletId: UserWalletId) { val networks = getSupportedNetworks(userWalletId) if (networks.isEmpty()) return setPendingFlag(userWalletId, value = true) - val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId) - tokenSyncStore.clear() + val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId) + assetsDiscoveryStore.clear() val batches = networks.chunked(MAX_CONCURRENT_REQUESTS) var completedNetworks = 0 - getProgressFlow(userWalletId).value = TokenSyncProgress.InProgress( + getProgressFlow(userWalletId).value = AssetsDiscoveryProgress.InProgress( completedNetworks = 0, totalNetworks = networks.size, ) @@ -114,25 +111,23 @@ internal class DefaultTokenSyncRepository( completedNetworks = handleBatchResults( userWalletId = userWalletId, results = batchResults, - tokenSyncStore = tokenSyncStore, + assetsDiscoveryStore = assetsDiscoveryStore, completedNetworks = completedNetworks, totalNetworks = networks.size, ) } } - override suspend fun completeSync(userWalletId: UserWalletId) { + override suspend fun completeDiscovery(userWalletId: UserWalletId) { setPendingFlag(userWalletId, value = false) - getProgressFlow(userWalletId).value = TokenSyncProgress.Completed + getProgressFlow(userWalletId).value = AssetsDiscoveryProgress.Completed } private suspend fun processBatch(userWalletId: UserWalletId, batch: List): List { return coroutineScope { batch.map { network -> async(dispatchers.io) { - semaphore.withPermit { - processNetwork(userWalletId, network) - } + processNetwork(userWalletId, network) } }.awaitAll() } @@ -141,7 +136,7 @@ internal class DefaultTokenSyncRepository( private suspend fun handleBatchResults( userWalletId: UserWalletId, results: List, - tokenSyncStore: TokenSyncStore, + assetsDiscoveryStore: AssetsDiscoveryStore, completedNetworks: Int, totalNetworks: Int, ): Int { @@ -150,8 +145,8 @@ internal class DefaultTokenSyncRepository( for (result in results) { completed++ - handleNetworkResult(result, tokenSyncStore) - progressFlow.value = TokenSyncProgress.InProgress( + handleNetworkResult(result, assetsDiscoveryStore) + progressFlow.value = AssetsDiscoveryProgress.InProgress( completedNetworks = completed, totalNetworks = totalNetworks, ) @@ -160,79 +155,78 @@ internal class DefaultTokenSyncRepository( return completed } - private suspend fun handleNetworkResult(result: NetworkResult, tokenSyncStore: TokenSyncStore) { + private suspend fun handleNetworkResult(result: NetworkResult, assetsDiscoveryStore: AssetsDiscoveryStore) { when (result) { is NetworkResult.Success -> { if (result.responseTokens.isNotEmpty()) { try { - tokenSyncStore.append(result.responseTokens) + assetsDiscoveryStore.append(result.responseTokens) } catch (e: Exception) { TangemLogger.e("Failed to store discovered tokens for network: ${result.networkId}", e) } } } is NetworkResult.Error -> { - TangemLogger.e("Token sync failed for network: ${result.networkId}", result.cause) + TangemLogger.e("Assets discovery failed for network: ${result.networkId}", result.cause) } } } private suspend fun processNetwork(userWalletId: UserWalletId, network: Network): NetworkResult { return try { - val tokenBalances = fetchAndFilterTokenBalances(userWalletId, network) + val discoveredAssets = discoverAndFilterAssets(userWalletId, network) - if (tokenBalances.isEmpty()) { + if (discoveredAssets.isEmpty()) { return NetworkResult.Success( - networkId = network.backendId, + networkId = network.rawId, responseTokens = emptyList(), ) } - val enrichedTokens = enrichTokensWithCatalog(tokenBalances, network) + val enrichedTokens = enrichTokensWithCatalog(discoveredAssets, network) val responseTokens = enrichedTokens .filter { it.contractAddress != null } .map { it.toResponseToken() } NetworkResult.Success( - networkId = network.backendId, + networkId = network.rawId, responseTokens = responseTokens, ) } catch (e: Exception) { - NetworkResult.Error(networkId = network.backendId, cause = e) + NetworkResult.Error(networkId = network.rawId, cause = e) } } - private suspend fun fetchAndFilterTokenBalances(userWalletId: UserWalletId, network: Network): List { - return withContext(dispatchers.io) { - walletManagersFacade.getTokenBalances(userWalletId, network) + private suspend fun discoverAndFilterAssets(userWalletId: UserWalletId, network: Network): List = + withContext(dispatchers.io) { + val providerInfo = assetsDiscoveryFacade.getAssetsDiscoveryService(userWalletId, network) + ?: return@withContext emptyList() + providerInfo.service.discoverAssets(providerInfo.address) .filter { it.amount > BigDecimal.ZERO } } - } private suspend fun enrichTokensWithCatalog( - tokenBalances: List, + assets: List, network: Network, - ): List = withContext(dispatchers.io) { - val tokensToEnrich = tokenBalances.filter { !it.isNativeToken } + ): List = withContext(dispatchers.io) { + val tokensToEnrich = assets.filterIsInstance() val catalogMap = fetchCatalogInfo( - networkId = network.backendId, - contractAddresses = tokensToEnrich.mapNotNull(TokenBalance::contractAddress), + networkId = network.rawId, + contractAddresses = tokensToEnrich.map { it.contractAddress }, ) - tokenBalances.mapNotNull { balance -> - if (balance.isNativeToken) return@mapNotNull null - - val contractAddressLower = balance.contractAddress?.lowercase() - val coin = contractAddressLower?.let { catalogMap[it] } ?: return@mapNotNull null + tokensToEnrich.mapNotNull { balance -> + val contractAddressLower = balance.contractAddress.lowercase() + val coin = catalogMap[contractAddressLower] ?: return@mapNotNull null val decimals = coin.networks .find { it.contractAddress?.lowercase() == contractAddressLower } ?.decimalCount ?.toInt() ?: 0 - DiscoveredToken( + EnrichedDiscoveredAsset( contractAddress = balance.contractAddress, symbol = coin.symbol, name = coin.name, @@ -240,7 +234,7 @@ internal class DefaultTokenSyncRepository( amount = balance.amount, isNativeToken = false, currencyId = coin.id, - networkId = network.backendId, + networkId = network.rawId, ) } } @@ -290,23 +284,23 @@ internal class DefaultTokenSyncRepository( } } - private fun getProgressFlow(userWalletId: UserWalletId): MutableStateFlow { + private fun getProgressFlow(userWalletId: UserWalletId): MutableStateFlow { return progressStates.getOrPut(userWalletId.stringValue) { - MutableStateFlow(TokenSyncProgress.Idle) + MutableStateFlow(AssetsDiscoveryProgress.Idle) } } private suspend fun setPendingFlag(userWalletId: UserWalletId, value: Boolean) { appPreferencesStore.editData { prefs -> prefs.setObjectMap( - key = PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY, - value = prefs.getObjectMap(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY) + key = PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY, + value = prefs.getObjectMap(PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY) .plus(userWalletId.stringValue to value), ) } } - private fun DiscoveredToken.toResponseToken(): UserTokensResponse.Token { + private fun EnrichedDiscoveredAsset.toResponseToken(): UserTokensResponse.Token { return UserTokensResponse.Token( id = currencyId, networkId = networkId, @@ -317,7 +311,7 @@ internal class DefaultTokenSyncRepository( ) } - private data class DiscoveredToken( + private data class EnrichedDiscoveredAsset( val contractAddress: String?, val symbol: String, val name: String, diff --git a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt similarity index 73% rename from data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStore.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt index 919b551121..fa1b8c658e 100644 --- a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt @@ -1,8 +1,8 @@ -package com.tangem.data.tokensync.store +package com.tangem.data.assetsdiscovery.store import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -interface TokenSyncStore { +interface AssetsDiscoveryStore { suspend fun get(): List diff --git a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStoreFactory.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStoreFactory.kt similarity index 86% rename from data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStoreFactory.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStoreFactory.kt index 43c191b61c..914c387d7d 100644 --- a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStoreFactory.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStoreFactory.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokensync.store +package com.tangem.data.assetsdiscovery.store import android.content.Context import androidx.datastore.core.DataStore @@ -17,18 +17,18 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class TokenSyncStoreFactory @Inject constructor( +class AssetsDiscoveryStoreFactory @Inject constructor( @NetworkMoshi private val moshi: Moshi, @ApplicationContext private val context: Context, private val appScope: AppCoroutineScope, ) { - private val stores = ConcurrentHashMap() + private val stores = ConcurrentHashMap() - fun provide(userWalletId: UserWalletId): TokenSyncStore { + fun provide(userWalletId: UserWalletId): AssetsDiscoveryStore { val userWalletStringId = userWalletId.formatted() return stores.computeIfAbsent(userWalletStringId) { - DefaultTokenSyncStore( + DefaultAssetsDiscoveryStore( persistenceStore = createPersistenceStore( fileName = "token_sync_$userWalletStringId", types = listTypes(), diff --git a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/DefaultTokenSyncStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt similarity index 84% rename from data/tokensync/src/main/java/com/tangem/data/tokensync/store/DefaultTokenSyncStore.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt index d498d28ca3..9bc4d75f7f 100644 --- a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/DefaultTokenSyncStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt @@ -1,12 +1,12 @@ -package com.tangem.data.tokensync.store +package com.tangem.data.assetsdiscovery.store import androidx.datastore.core.DataStore import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import kotlinx.coroutines.flow.firstOrNull -internal class DefaultTokenSyncStore( +internal class DefaultAssetsDiscoveryStore( private val persistenceStore: DataStore>, -) : TokenSyncStore { +) : AssetsDiscoveryStore { override suspend fun get(): List { return persistenceStore.data.firstOrNull().orEmpty() diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt index 59cf796d98..a5da6c9298 100644 --- a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt @@ -33,6 +33,7 @@ internal class DefaultBlockAidRepository( return when (data.params) { is TransactionParams.Evm -> scanEvmTransaction(data = data) is TransactionParams.Solana -> scanSolanaTransaction(data = data) + is TransactionParams.Bitcoin -> scanBitcoinTransaction(data = data) } } @@ -59,6 +60,17 @@ internal class DefaultBlockAidRepository( mapper.mapToDomain(response) } + @Suppress("UnusedParameter") + private fun scanBitcoinTransaction(data: TransactionData): CheckTransactionResult { + // TODO: BlockAid API doesn't support Bitcoin transaction scanning yet + // When support is added, implement: api.scanBitcoinTransaction(mapper.mapToBitcoinRequest(data)) + return CheckTransactionResult( + validation = com.domain.blockaid.models.transaction.ValidationResult.FAILED_TO_VALIDATE, + description = "Bitcoin transaction validation is not yet supported by BlockAid", + simulation = com.domain.blockaid.models.transaction.SimulationResult.FailedToSimulate, + ) + } + private suspend fun scanEvmTransactionBulk( blockchain: Blockchain, transactionDataList: List, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt index 4b7cffe26c..8773f976e7 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt @@ -169,7 +169,7 @@ class CryptoCurrencyFactory( id = cryptoCurrency.id.rawCurrencyId?.value, ) val blockchain = - Blockchain.fromNetworkId(cryptoCurrency.network.backendId) ?: Blockchain.Unknown + Blockchain.fromNetworkId(cryptoCurrency.network.rawId) ?: Blockchain.Unknown val id = getTokenId(network, sdkToken) return CryptoCurrency.Token( id = id, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt index f56f43a761..c3a4e59b8d 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt @@ -47,7 +47,7 @@ internal class DefaultCardCryptoCurrencyFactory( // check if the blockchain of single-currency wallet is the same as network val cardNetworkId = userWallet.scanResponse.cardTypesResolver.getBlockchain().toNetworkId() - val cardNetwork = networks.firstOrNull { it.backendId == cardNetworkId } + val cardNetwork = networks.firstOrNull { it.rawId == cardNetworkId } if (cardNetwork == null) return emptyMap() @@ -145,7 +145,7 @@ internal class DefaultCardCryptoCurrencyFactory( responseCryptoCurrenciesFactory.createCurrencies( tokens = accountDTO.tokens.orEmpty().filter { token -> networks.any { - it.backendId == token.networkId && it.derivationPath.value == token.derivationPath + it.rawId == token.networkId && it.derivationPath.value == token.derivationPath } }, userWallet = userWallet, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index 85bf2f102f..9bd1896c03 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -40,7 +40,7 @@ class UserTokensResponseFactory @Inject constructor() { UserTokensResponse.Token( id = id.rawCurrencyId?.value, accountId = accountId?.value, - networkId = network.backendId, + networkId = network.rawId, derivationPath = network.derivationPath.value, name = name, symbol = symbol, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/locale/DefaultLocaleProvider.kt b/data/common/src/main/kotlin/com/tangem/data/common/locale/DefaultLocaleProvider.kt deleted file mode 100644 index 55cdf424ec..0000000000 --- a/data/common/src/main/kotlin/com/tangem/data/common/locale/DefaultLocaleProvider.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.data.common.locale - -import java.util.Locale - -/** -[REDACTED_AUTHOR] - */ -internal class DefaultLocaleProvider : LocaleProvider { - - override fun getLocale(): Locale { - return Locale.getDefault() - } - - override fun getWebUriLocaleLanguage(): String { - val language = getLocale().language - return if (LOCALE_LANG_RU.equals(language, true) || LOCALE_LANG_BY.equals(language, true)) { - LOCALE_LANG_RU - } else { - LOCALE_LANG_EN - } - } - - companion object { - const val LOCALE_LANG_RU = "ru" - const val LOCALE_LANG_BY = "by" - const val LOCALE_LANG_EN = "en" - } -} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/locale/LocaleProvider.kt b/data/common/src/main/kotlin/com/tangem/data/common/locale/LocaleProvider.kt deleted file mode 100644 index 5f52c75915..0000000000 --- a/data/common/src/main/kotlin/com/tangem/data/common/locale/LocaleProvider.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.data.common.locale - -import java.util.Locale - -/** -[REDACTED_AUTHOR] - */ -interface LocaleProvider { - - fun getLocale(): Locale - - fun getWebUriLocaleLanguage(): String -} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/locale/di/LocaleProviderModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/locale/di/LocaleProviderModule.kt deleted file mode 100644 index 656c6ed529..0000000000 --- a/data/common/src/main/kotlin/com/tangem/data/common/locale/di/LocaleProviderModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.data.common.locale.di - -import com.tangem.data.common.locale.DefaultLocaleProvider -import com.tangem.data.common.locale.LocaleProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object LocaleProviderModule { - - @Provides - @Singleton - fun provideCacheRegistry(): LocaleProvider { - return DefaultLocaleProvider() - } -} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 97ba053720..4d9c4d9cdc 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -136,8 +136,7 @@ class NetworkFactory @Inject constructor( return runCatching { Network( - id = Network.ID(value = blockchain.id, derivationPath = derivationPath), - backendId = blockchain.toNetworkId(), + id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath), name = blockchain.fullName, isTestnet = blockchain.isTestnet(), derivationPath = derivationPath, diff --git a/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt index e0fc158abe..cfc2969751 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.common.network import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory @@ -187,7 +188,7 @@ class NetworkFactoryTest { userWallet = userWallet, expected = MockCryptoCurrencyFactory().ethereum.network.copy( id = Network.ID( - value = Blockchain.Ethereum.id, + value = Blockchain.Ethereum.toNetworkId(), derivationPath = expectedDerivationPath, ), derivationPath = expectedDerivationPath, @@ -201,12 +202,12 @@ class NetworkFactoryTest { derivationPath: Network.DerivationPath, ): CreateTestModel.Second { return CreateTestModel.Second( - networkId = Network.ID(value = Blockchain.Ethereum.id, derivationPath = derivationPath), + networkId = Network.ID(value = Blockchain.Ethereum.toNetworkId(), derivationPath = derivationPath), derivationPath = derivationPath, userWallet = userWallet, expected = MockCryptoCurrencyFactory().ethereum.network.copy( id = Network.ID( - value = Blockchain.Ethereum.id, + value = Blockchain.Ethereum.toNetworkId(), derivationPath = derivationPath, ), derivationPath = derivationPath, @@ -227,7 +228,7 @@ class NetworkFactoryTest { derivationStyleProvider = derivationStyleProvider, canHandleTokens = canHandleTokens, expected = MockCryptoCurrencyFactory().ethereum.network.copy( - id = Network.ID(value = Blockchain.Ethereum.id, derivationPath = expectedDerivationPath), + id = Network.ID(value = Blockchain.Ethereum.toNetworkId(), derivationPath = expectedDerivationPath), derivationPath = expectedDerivationPath, canHandleTokens = canHandleTokens, ), diff --git a/data/dynamic-addresses/build.gradle.kts b/data/dynamic-addresses/build.gradle.kts index 47a15f3e68..0c1a34ea9b 100644 --- a/data/dynamic-addresses/build.gradle.kts +++ b/data/dynamic-addresses/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.data.dynamicaddresses" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // region Project - Core implementation(projects.core.configToggles) @@ -30,10 +34,16 @@ dependencies { // region Project - Libs implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(tangemDeps.card.core) // endregion // region DI implementation(deps.hilt.android) kapt(deps.hilt.kapt) // endregion + + // region Testing + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.test.core) + // endregion } \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt index 0860a7b9ce..c1b88d69d2 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt @@ -1,10 +1,12 @@ package com.tangem.data.dynamicaddresses import arrow.core.Either +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.DynamicAddressesManager import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.WalletManager import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository import com.tangem.domain.models.network.Network @@ -33,13 +35,25 @@ internal class DefaultConsolidationRepository( when (val result = dynamicAddressesManager.createConsolidationTransaction(fee)) { is Result.Success -> result.data - is Result.Failure -> error("Failed to create consolidation tx: ${result.error}") + is Result.Failure -> throw result.error } } } - @Suppress("UnusedParameter") - private fun getNormalFee(walletManager: WalletManager): Fee { - TODO("Fee calculation for consolidation from multiple addresses is not yet implemented") + private suspend fun getNormalFee(walletManager: WalletManager): Fee { + val coinAmount = walletManager.wallet.amounts[AmountType.Coin] + ?: error("Coin amount not found") + val feeResult = walletManager.getFee( + amount = coinAmount, + destination = walletManager.wallet.address, + ) + + return when (feeResult) { + is Result.Success -> when (val txFee = feeResult.data) { + is TransactionFee.Single -> txFee.normal + is TransactionFee.Choosable -> txFee.normal + } + is Result.Failure -> throw feeResult.error + } } } \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index 28c3c017b6..7342b9439d 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -5,55 +5,91 @@ import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.blockchain.extensions.SimpleResult import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap +@Suppress("LongParameterList") internal class DefaultDynamicAddressesRepository( private val walletAccountsFetcher: WalletAccountsFetcher, private val walletAccountsSaver: WalletAccountsSaver, private val accountsCRUDRepository: AccountsCRUDRepository, private val walletManagersFacade: WalletManagersFacade, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val getDerivedXpubUseCase: GetDerivedXpubUseCase, private val dispatchers: CoroutineDispatcherProvider, ) : DynamicAddressesRepository { + private val extraFundsProbeCache = ConcurrentHashMap, Boolean>() + private val extraFundsProbeMutex = Mutex() + override fun getStatus(userWalletId: UserWalletId, network: Network): Flow { return walletAccountsFetcher.get(userWalletId) .map { response -> val token = response.findToken(network) when { - token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED - else -> DynamicAddressesStatus.DISABLED + token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED + !walletManagersFacade.isDynamicAddressesEnabled(userWalletId, network) -> + DynamicAddressesStatus.ENABLED_REQUIRES_SETUP + else -> DynamicAddressesStatus.ENABLED } - // TODO handle ENABLED_REQUIRES_SETUP when XPUB is not derived locally } .flowOn(dispatchers.io) } override suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String) { withContext(dispatchers.io) { - walletManagersFacade.enableXpubMode(userWalletId, network, xpub) + val result = walletManagersFacade.enableXpubMode(userWalletId, network, xpub) + if (result is SimpleResult.Failure) { + error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}") + } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true) + invalidateExtraFundsProbe(userWalletId, network) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } - .onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) } + .onFailure { throwable -> + TangemLogger.e( + messageString = "Failed to sync tokens after dynamic addresses enable for $userWalletId", + throwable = throwable, + ) + } } } override suspend fun disable(userWalletId: UserWalletId, network: Network) { withContext(dispatchers.io) { - walletManagersFacade.disableXpubMode(userWalletId, network) + val result = walletManagersFacade.disableXpubMode(userWalletId, network) + if (result is SimpleResult.Failure) { + error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}") + } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false) + invalidateExtraFundsProbe(userWalletId, network) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } - .onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) } + .onFailure { throwable -> + TangemLogger.e( + messageString = "Failed to sync tokens after dynamic addresses disable for $userWalletId", + throwable = throwable, + ) + } } } @@ -70,6 +106,75 @@ internal class DefaultDynamicAddressesRepository( return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network) } + override fun hasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network): Flow { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return flowOf(false) + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.id.rawId.value)) return flowOf(false) + + return getStatus(userWalletId, network) + .distinctUntilChanged() + .map { status -> + if (status != DynamicAddressesStatus.DISABLED) return@map false + probeExtraFundsCached(userWalletId, network) + } + .onStart { emit(false) } + .flowOn(dispatchers.io) + } + + private suspend fun probeExtraFundsCached(userWalletId: UserWalletId, network: Network): Boolean { + val key = userWalletId to network + extraFundsProbeCache[key]?.let { return it } + return extraFundsProbeMutex.withLock { + extraFundsProbeCache[key]?.let { return@withLock it } + + val xpub = getDerivedXpubUseCase(userWalletId, network) ?: return@withLock false + + val hasFunds = walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, xpub) + if (hasFunds) extraFundsProbeCache[key] = true + hasFunds + } + } + + private suspend fun invalidateExtraFundsProbe(userWalletId: UserWalletId, network: Network) { + extraFundsProbeMutex.withLock { + extraFundsProbeCache.remove(userWalletId to network) + } + } + + override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean { + return withContext(dispatchers.io) { + val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false + val baseDerivationPath = network.derivationPath.value ?: return@withContext false + + response.accounts + .flatMap { it.tokens.orEmpty() } + .any { token -> + val tokenDerivationPath = token.derivationPath ?: return@any false + token.networkId == network.id.rawId.value && + tokenDerivationPath != baseDerivationPath && + DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex( + customPath = tokenDerivationPath, + basePath = baseDerivationPath, + ) + } + } + } + + override fun isDynamicAddressesEnabledForNetwork( + userWalletId: UserWalletId, + networkId: Network.ID, + ): Flow { + return walletAccountsFetcher.get(userWalletId) + .map { response -> + response.accounts + .flatMap { it.tokens.orEmpty() } + .any { token -> + token.matchesNetwork(networkId) && + token.dynamicAddressesEnabled == true + } + } + .flowOn(dispatchers.io) + } + private suspend fun updateTokenDynamicAddressesFlag( userWalletId: UserWalletId, network: Network, @@ -80,7 +185,7 @@ internal class DefaultDynamicAddressesRepository( accounts = response.accounts.map { account -> account.copy( tokens = account.tokens?.map { token -> - if (token.matchesNetwork(network)) { + if (token.matchesNetwork(network.id)) { token.copy(dynamicAddressesEnabled = enabled) } else { token @@ -95,12 +200,12 @@ internal class DefaultDynamicAddressesRepository( private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? { return accounts .flatMap { it.tokens.orEmpty() } - .find { it.matchesNetwork(network) } + .find { it.matchesNetwork(network.id) } } - private fun UserTokensResponse.Token.matchesNetwork(network: Network): Boolean { - return networkId == network.backendId && - derivationPath == network.derivationPath.value && + private fun UserTokensResponse.Token.matchesNetwork(networkId: Network.ID): Boolean { + return this.networkId == networkId.rawId.value && + derivationPath == networkId.derivationPath.value && contractAddress == null } } \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt new file mode 100644 index 0000000000..ec96dcdcea --- /dev/null +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt @@ -0,0 +1,45 @@ +package com.tangem.data.dynamicaddresses + +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Provides XPUB strings for networks that need dynamic addresses restore (ENABLED_REQUIRES_SETUP). + * Uses only already-derived keys — no card scan triggered. + */ +@Singleton +class DynamicAddressesInitializer @Inject constructor( + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val getDerivedXpubUseCase: GetDerivedXpubUseCase, +) { + + suspend fun getXpubs(userWalletId: UserWalletId, networks: Set): Map { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return emptyMap() + + val result = mutableMapOf() + for (network in networks) { + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.rawId)) continue + + val status = dynamicAddressesRepository.getStatus(userWalletId, network).firstOrNull() + if (status != DynamicAddressesStatus.ENABLED_REQUIRES_SETUP) continue + + val xpub = getDerivedXpubUseCase(userWalletId, network) + if (xpub != null) { + result[network] = xpub + } else { + TangemLogger.w("Dynamic addresses enabled but XPUB not available for ${network.id}") + } + } + return result + } +} \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt index 8ace9bf67b..141f6a756e 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt @@ -8,6 +8,7 @@ import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.data.dynamicaddresses.DefaultDynamicAddressesFeatureToggles import com.tangem.data.dynamicaddresses.DefaultDynamicAddressesRepository import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.walletmanager.WalletManagersFacade @@ -29,6 +30,8 @@ internal object DynamicAddressesDataModule { walletAccountsSaver: WalletAccountsSaver, accountsCRUDRepository: AccountsCRUDRepository, walletManagersFacade: WalletManagersFacade, + dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + getDerivedXpubUseCase: GetDerivedXpubUseCase, dispatchers: CoroutineDispatcherProvider, ): DynamicAddressesRepository { return DefaultDynamicAddressesRepository( @@ -36,6 +39,8 @@ internal object DynamicAddressesDataModule { walletAccountsSaver = walletAccountsSaver, accountsCRUDRepository = accountsCRUDRepository, walletManagersFacade = walletManagersFacade, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, + getDerivedXpubUseCase = getDerivedXpubUseCase, dispatchers = dispatchers, ) } diff --git a/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt b/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt new file mode 100644 index 0000000000..4202bd889b --- /dev/null +++ b/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt @@ -0,0 +1,218 @@ +package com.tangem.data.dynamicaddresses + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DefaultDynamicAddressesRepositoryTest { + + private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxed = true) + private val walletAccountsSaver: WalletAccountsSaver = mockk(relaxed = true) + private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true) + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val featureToggles: DynamicAddressesFeatureToggles = mockk(relaxed = true) + private val getDerivedXpubUseCase: GetDerivedXpubUseCase = mockk(relaxed = true) + + private val userWalletId: UserWalletId = mockk(relaxed = true) + private val network: Network = mockk(relaxed = true) { + every { id.rawId.value } returns SUPPORTED_NETWORK_ID + } + private val otherNetwork: Network = mockk(relaxed = true) { + every { id.rawId.value } returns OTHER_SUPPORTED_NETWORK_ID + } + + private val dispatchers: CoroutineDispatcherProvider = TestDispatchers(Dispatchers.Unconfined) + + private lateinit var repository: DefaultDynamicAddressesRepository + + @BeforeEach + fun setUp() { + clearMocks(walletManagersFacade, featureToggles, getDerivedXpubUseCase, answers = false) + // Empty response → findToken returns null → getStatus emits DISABLED. + val emptyResponse = mockk(relaxed = true) { + every { accounts } returns emptyList() + } + every { walletAccountsFetcher.get(userWalletId) } returns flowOf(emptyResponse) + coEvery { walletManagersFacade.enableXpubMode(any(), any(), any()) } returns SimpleResult.Success + coEvery { walletManagersFacade.disableXpubMode(any(), any()) } returns SimpleResult.Success + + repository = DefaultDynamicAddressesRepository( + walletAccountsFetcher = walletAccountsFetcher, + walletAccountsSaver = walletAccountsSaver, + accountsCRUDRepository = accountsCRUDRepository, + walletManagersFacade = walletManagersFacade, + dynamicAddressesFeatureToggles = featureToggles, + getDerivedXpubUseCase = getDerivedXpubUseCase, + dispatchers = dispatchers, + ) + } + + @Test + fun `GIVEN feature toggle off WHEN collect THEN probe is never called and flow emits false`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns false + + // WHEN + val values = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + assertThat(values).doesNotContain(true) + coVerify(exactly = 0) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(any(), any(), any()) + } + } + + @Test + fun `GIVEN toggle on AND xpub is null WHEN collect THEN probe is not called AND cache is empty`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns null + + // WHEN + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + // Second collect should invoke xpub derivation again (nothing is cached for null xpub). + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + coVerify(exactly = 2) { getDerivedXpubUseCase(userWalletId, network) } + coVerify(exactly = 0) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(any(), any(), any()) + } + } + + @Test + fun `GIVEN probe returns true WHEN collect THEN result is cached AND next collect skips probe`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + + // WHEN + val firstValues = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + val secondValues = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + assertThat(firstValues).contains(true) + assertThat(secondValues).contains(true) + coVerify(exactly = 1) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN probe returns false WHEN collect twice THEN probe runs each time`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns false + + // WHEN + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN — negative results are not cached, so the probe must re-run. + coVerify(exactly = 2) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN cached true WHEN enable succeeds THEN cache is invalidated and next probe runs again`() = runTest { + // GIVEN — populate cache with a positive probe + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + repository.hasFundsOnAdditionalAddresses(userWalletId, network).first() + + // WHEN — enable() succeeds and invalidates the cache + repository.enable(userWalletId, network, XPUB) + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN — probe was called once before enable, again after invalidation + coVerify(exactly = 2) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN cached true WHEN disable succeeds THEN cache is invalidated and next probe runs again`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + repository.hasFundsOnAdditionalAddresses(userWalletId, network).first() + + // WHEN + repository.disable(userWalletId, network) + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + coVerify(exactly = 2) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN cache entry for one network WHEN invalidate other network THEN first entry is preserved`() = runTest { + // GIVEN — cache populated for `network` + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + repository.hasFundsOnAdditionalAddresses(userWalletId, network).first() + + // WHEN — disable invalidates a different network + repository.disable(userWalletId, otherNetwork) + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN — `network` cache is untouched; probe was called only once (initial fill) + coVerify(exactly = 1) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + private class TestDispatchers(dispatcher: CoroutineDispatcher) : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val single: CoroutineDispatcher = dispatcher + } + + private companion object { + const val XPUB = "xpub6-test-value" + const val SUPPORTED_NETWORK_ID = "bitcoin" + const val OTHER_SUPPORTED_NETWORK_ID = "litecoin" + } +} \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index 5f39f9f870..511dcdc2e5 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt @@ -1,7 +1,6 @@ package com.tangem.data.feedback -import android.os.Build -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.core.navigation.email.EmailSender import com.tangem.data.feedback.converters.BlockchainInfoConverter import com.tangem.data.feedback.converters.WalletMetaInfoConverter @@ -10,12 +9,12 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.utils.info.AppInfoProvider import com.tangem.utils.logging.TangemLogger -import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import java.io.File @@ -23,23 +22,20 @@ import java.io.File /** * Implementation of [FeedbackRepository] * - * @property appLogsStore app logs store - * @property userWalletsListManager user wallets list manager - * @property userWalletsListRepository user wallets repository - * @property walletManagersStore wallet managers store - * @property emailSender email sender - * @property appVersionProvider app version provider + * @property appLogsStore app logs store + * @property userWalletsListRepository repository for getting user wallets + * @property walletManagersStore wallet managers store + * @property emailSender email sender + * @property appInfoProvider app info provider * [REDACTED_AUTHOR] */ -@Suppress("LongParameterList") internal class DefaultFeedbackRepository( private val appLogsStore: AppLogsStore, private val userWalletsListRepository: UserWalletsListRepository, private val walletManagersStore: WalletManagersStore, private val emailSender: EmailSender, - private val appVersionProvider: AppVersionProvider, - private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val appInfoProvider: AppInfoProvider, ) : FeedbackRepository { private val blockchainsErrors = MutableStateFlow>(emptyMap()) @@ -68,30 +64,26 @@ internal class DefaultFeedbackRepository( .map(BlockchainInfoConverter::convert) } - override suspend fun getBlockchainInfo( - userWalletId: UserWalletId, - blockchainId: String, - derivationPath: String?, - ): BlockchainInfo? { + override suspend fun getBlockchainInfo(userWalletId: UserWalletId, networkId: Network.ID): BlockchainInfo? { return walletManagersStore .getSyncOrNull( userWalletId = userWalletId, - blockchain = Blockchain.fromId(blockchainId), - derivationPath = derivationPath, + blockchain = networkId.toBlockchain(), + derivationPath = networkId.derivationPath.value, ) ?.let(BlockchainInfoConverter::convert) } override fun getPhoneInfo(): PhoneInfo { return PhoneInfo( - phoneModel = Build.MODEL, - osVersion = Build.VERSION.SDK_INT.toString(), - appVersion = appVersionProvider.versionName, + phoneModel = appInfoProvider.device, + osVersion = appInfoProvider.sdkVersion.toString(), + appVersion = appInfoProvider.appVersion, ) } override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) { - val userWallet = getSelectedWalletUseCase.sync().getOrNull() ?: error("UserWallet is not selected") + val userWallet = userWalletsListRepository.selectedUserWallet.value ?: error("UserWallet is not selected") blockchainsErrors.update { map -> map.toMutableMap().apply { diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt index db803a21d2..05133c89d1 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt @@ -9,8 +9,7 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.feedback.repository.FeedbackRepository -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -28,16 +27,14 @@ internal object FeedbackModule { userWalletsListRepository: UserWalletsListRepository, walletManagersStore: WalletManagersStore, emailSender: EmailSender, - appVersionProvider: AppVersionProvider, - getSelectedWalletUseCase: GetSelectedWalletUseCase, + appInfoProvider: AppInfoProvider, ): FeedbackRepository { return DefaultFeedbackRepository( appLogsStore = appLogsStore, walletManagersStore = walletManagersStore, emailSender = emailSender, - appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, userWalletsListRepository = userWalletsListRepository, - getSelectedWalletUseCase = getSelectedWalletUseCase, ) } diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt index dcf575b79f..db25b8597e 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -91,7 +91,7 @@ internal class DefaultCustomTokensRepository( val response = tangemTechApi.getCoins( contractAddress = contractAddress, - networkIds = network.backendId, + networkIds = network.rawId, active = true, ).getOrThrow() diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index 799793848c..95992352f7 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -247,7 +247,7 @@ internal class DefaultMarketsTokenRepository( override suspend fun createCryptoCurrency( userWalletId: UserWalletId, - token: TokenMarketParams, + token: RawMarketToken, network: TokenMarketInfo.Network, accountIndex: DerivationIndex?, ): CryptoCurrency? { diff --git a/data/networks/build.gradle.kts b/data/networks/build.gradle.kts index d90e4e2a52..67c7f4eb40 100644 --- a/data/networks/build.gradle.kts +++ b/data/networks/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { // region Project - Data implementation(projects.data.common) + implementation(projects.data.dynamicAddresses) // endregion // region Project - Domain diff --git a/data/networks/detekt-baseline-debug.xml b/data/networks/detekt-baseline-debug.xml deleted file mode 100644 index 981dd137d6..0000000000 --- a/data/networks/detekt-baseline-debug.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - MultilineLambdaItParameter:CommonNetworkStatusFetcher.kt$CommonNetworkStatusFetcher${ Timber.e("Failed to fetch network status for $userWalletId [${network.rawId}]: $it") networksStatusesStore.setSourceAsOnlyCache(userWalletId = userWalletId, network = network) } - MultilineLambdaItParameter:DefaultMultiNetworkStatusFetcher.kt$DefaultMultiNetworkStatusFetcher${ networksStatusesStore.setSourceAsOnlyCache( userWalletId = params.userWalletId, networks = params.networks, ) raise(it) } - MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return emptyList() } - MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return@withContext } - MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null CurrencyAmount( id = currencyIdConverter.convertBack(value = it.key), amount = amount.value, ) } - MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val currencyId = currencyIdConverter.convert(value = it.id) val amount = NetworkStatus.Amount.Loaded(value = it.amount) currencyId to amount } - MultilineLambdaItParameter:NetworkStatusSupplierModule.kt$NetworkStatusSupplierModule.<no name provided>${ "single_network_status_${it.userWalletId.stringValue}_${it.network.rawId}_" + it.network.derivationPath.value } - MultilineLambdaItParameter:NetworkYieldSupplyStatusConverter.kt$NetworkYieldSupplyStatusConverter${ val id = currencyIdConverter.convert(value = it.id) val status = YieldSupplyStatus( isActive = it.isActive, isInitialized = it.isInitialized, isAllowedToSpend = it.isAllowedToSpend, effectiveProtocolBalance = it.effectiveProtocolBalance, ) id to status } - SuspendFunSwallowedCancellation:DefaultNetworksRepository.kt$DefaultNetworksRepository$runCatching - - diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt index a2876c0071..dc74b16cd8 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt @@ -12,33 +12,33 @@ private typealias AmountsDomainModel = Map { - private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath) + private val currencyIdConverter = NetworkCurrencyIdConverter(blockchainId, derivationPath) override fun convert(value: AmountsDataModel): AmountsDomainModel { - return value.associate { - val currencyId = currencyIdConverter.convert(value = it.id) - val amount = NetworkStatus.Amount.Loaded(value = it.amount) + return value.associate { currencyAmount -> + val currencyId = currencyIdConverter.convert(value = currencyAmount.id) + val amount = NetworkStatus.Amount.Loaded(value = currencyAmount.amount) currencyId to amount } } override fun convertBack(value: AmountsDomainModel): AmountsDataModel { - return value.mapNotNull { - val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null + return value.mapNotNull { (currencyId, networkAmount) -> + val amount = networkAmount as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null CurrencyAmount( - id = currencyIdConverter.convertBack(value = it.key), + id = currencyIdConverter.convertBack(value = currencyId), amount = amount.value, ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt similarity index 77% rename from data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt rename to data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt index 728b1db741..c8d7b12399 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt @@ -1,7 +1,9 @@ package com.tangem.data.networks.converters import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId.Companion.CONTRACT_ADDRESS_DELIMITER import com.tangem.domain.models.currency.CryptoCurrency @@ -12,16 +14,22 @@ import com.tangem.domain.models.currency.CryptoCurrency.ID.Suffix as CurrencyIdS /** * Converts between [CurrencyId] and [CryptoCurrency.ID]. * - * @property rawNetworkId the raw network ID associated with the currency + * @property blockchainId the blockchain ID associated with the currency * @property derivationPath the derivation path used for the network * [REDACTED_AUTHOR] */ -internal class CurrencyIdConverter( - private val rawNetworkId: String, +internal class NetworkCurrencyIdConverter( + private val blockchainId: String, private val derivationPath: Network.DerivationPath, ) : TwoWayConverter { + // Cache stores blockchainId in legacy format (e.g. "BTC"), but runtime + // CryptoCurrency.ID expects the new network rawId (e.g. "bitcoin") matching + // Network.rawId built from Blockchain.toNetworkId(). Convert once on construction + // so that IDs reconstructed from cache match those built at runtime. + private val networkRawId: String = Blockchain.fromId(blockchainId).toNetworkId() + override fun convert(value: CurrencyId): CryptoCurrency.ID { val suffixParts = value.value.split(CONTRACT_ADDRESS_DELIMITER) @@ -31,7 +39,7 @@ internal class CurrencyIdConverter( return if (contractAddress.isNullOrBlank()) { getCoinId( coinId = rawId.takeUnless { it.isNullOrBlank() } - ?: error("Coin id is null for $rawNetworkId with $derivationPath"), + ?: error("Coin id is null for $blockchainId with $derivationPath"), ) } else { getTokenId( @@ -43,14 +51,12 @@ internal class CurrencyIdConverter( override fun convertBack(value: CryptoCurrency.ID): CurrencyId { return if (value.isCoin) { - CurrencyId.createCoinId( - coinId = Blockchain.fromId(value.rawNetworkId).toCoinId(), - ) + CurrencyId.createCoinId(coinId = value.toBlockchain().toCoinId()) } else { CurrencyId.createTokenId( rawTokenId = value.rawCurrencyId?.value, contractAddress = requireNotNull(value.contractAddress) { - "Token contractAddress is null for token id: $this" + "Token contractAddress is null for token id: $value" }, ) } @@ -82,17 +88,17 @@ internal class CurrencyIdConverter( return when (derivationPath) { is Network.DerivationPath.Card -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = rawNetworkId, + rawId = networkRawId, derivationPath = derivationPath.value, ) } is Network.DerivationPath.Custom -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = rawNetworkId, + rawId = networkRawId, derivationPath = derivationPath.value, ) } - is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(rawNetworkId) + is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(networkRawId) } } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt index d6878c7f82..7f0d1b6fd3 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt @@ -1,5 +1,6 @@ package com.tangem.data.networks.converters +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.network.NetworkStatus import com.tangem.utils.converter.Converter @@ -15,17 +16,18 @@ internal object NetworkStatusDataModelConverter : Converter { val address = NetworkAddressConverter.convertBack(value = status.address) + val blockchainId = value.network.toBlockchain().id val amountsConverter = NetworkAmountsConverter( - rawNetworkId = value.network.rawId, + blockchainId = blockchainId, derivationPath = value.network.derivationPath, ) val yieldSupplyStatusConverter = NetworkYieldSupplyStatusConverter( - rawNetworkId = value.network.rawId, + blockchainId = blockchainId, derivationPath = value.network.derivationPath, ) NetworkStatusDM.Verified( - networkId = NetworkStatusDM.ID(value = value.network.rawId), + networkId = NetworkStatusDM.ID(value = blockchainId), derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath), selectedAddress = address.selectedAddress, availableAddresses = address.addresses, @@ -35,9 +37,10 @@ internal object NetworkStatusDataModelConverter : Converter { val address = NetworkAddressConverter.convertBack(value = status.address) + val blockchainId = value.network.toBlockchain().id NetworkStatusDM.NoAccount( - networkId = NetworkStatusDM.ID(value = value.network.rawId), + networkId = NetworkStatusDM.ID(value = blockchainId), derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath), selectedAddress = address.selectedAddress, availableAddresses = address.addresses, diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt index 8f8c4c7792..f0741ce9f8 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverter.kt @@ -10,20 +10,20 @@ private typealias YieldSupplyStatusDataModel = List internal class NetworkYieldSupplyStatusConverter( - rawNetworkId: String, + blockchainId: String, derivationPath: Network.DerivationPath, ) : TwoWayConverter { - private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath) + private val currencyIdConverter = NetworkCurrencyIdConverter(blockchainId, derivationPath) override fun convert(value: YieldSupplyStatusDataModel): YieldSupplyStatusDomainModel { - return value.associate { - val id = currencyIdConverter.convert(value = it.id) + return value.associate { yieldSupplyStatus -> + val id = currencyIdConverter.convert(value = yieldSupplyStatus.id) val status = YieldSupplyStatus( - isActive = it.isActive, - isInitialized = it.isInitialized, - isAllowedToSpend = it.isAllowedToSpend, - effectiveProtocolBalance = it.effectiveProtocolBalance, + isActive = yieldSupplyStatus.isActive, + isInitialized = yieldSupplyStatus.isInitialized, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + effectiveProtocolBalance = yieldSupplyStatus.effectiveProtocolBalance, ) id to status diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt index 287e82b3fa..940cfdea59 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt @@ -1,5 +1,7 @@ package com.tangem.data.networks.converters +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.StatusSource @@ -23,14 +25,15 @@ internal object SimpleNetworkStatusConverter : Converter + listOf( + "single_network_status", + params.userWalletId.stringValue, + params.network.rawId, + params.network.derivationPath.value, + ) + .joinToString(separator = "_") }, - ) {} + ) } @Provides @Singleton fun provideMultiNetworkStatusSupplier(factory: MultiNetworkStatusProducer.Factory): MultiNetworkStatusSupplier { - return object : MultiNetworkStatusSupplier( + return MultiNetworkStatusSupplier( factory = factory, keyCreator = { "multi_networks_statuses_${it.userWalletId.stringValue}" }, - ) {} + ) } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt index 38625e0f4a..17293dccca 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt @@ -43,6 +43,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor( userWalletId: UserWalletId, network: Network, networkCurrencies: Set, + xpub: String? = null, ): Either { return Either.catchOn(dispatchers.default) { val result = withContext(dispatchers.io) { @@ -52,6 +53,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor( extraTokens = networkCurrencies .filterIsInstance() .toSet(), + xpub = xpub, ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt index b039edc5ba..1f3e0860a0 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -2,16 +2,18 @@ package com.tangem.data.networks.multi import arrow.core.raise.catch import arrow.core.raise.ensure -import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.setSourceAsCache import com.tangem.data.networks.store.setSourceAsOnlyCache +import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.core.utils.eitherOn import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -27,11 +29,11 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ -@Suppress("LongParameterList") internal class DefaultMultiNetworkStatusFetcher @Inject constructor( private val networksStatusesStore: NetworksStatusesStore, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher, + private val dynamicAddressesInitializer: DynamicAddressesInitializer, private val dispatchers: CoroutineDispatcherProvider, ) : MultiNetworkStatusFetcher { @@ -40,13 +42,21 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( val networksCurrencies = catch( block = { createNetworksCurrenciesMap(params) }, - catch = { + catch = { error -> networksStatusesStore.setSourceAsOnlyCache( userWalletId = params.userWalletId, networks = params.networks, ) - raise(it) + raise(error) + }, + ) + + val xpubByNetwork = catch( + block = { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) }, + catch = { error -> + TangemLogger.e("Failed to build XPUBs for restore", error) + emptyMap() }, ) @@ -58,6 +68,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( userWalletId = params.userWalletId, network = network, networkCurrencies = networksCurrencies[network].orEmpty().toSet(), + xpub = xpubByNetwork[network], ) } } diff --git a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt index b5a2fb854c..defc120690 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.withContext @@ -33,13 +34,12 @@ internal class DefaultNetworksRepository( override suspend fun fetchPendingTransactions(userWalletId: UserWalletId, network: Network) { withContext(dispatchers.default) { - val currencies = runCatching { + val currencies = runSuspendCatching { cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network) + }.getOrElse { error -> + TangemLogger.e("Unable to create wallet currencies", error) + return@withContext } - .getOrElse { error -> - TangemLogger.e("Unable to create wallet currencies", error) - return@withContext - } fetchPendingTransactions(userWalletId = userWalletId, network = network, currencies = currencies) } @@ -49,34 +49,34 @@ internal class DefaultNetworksRepository( userWalletId: UserWalletId, network: Network, ): List { - return runCatching { cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network) } - .getOrElse { error -> - TangemLogger.e("Unable to create wallet currencies", error) - return emptyList() - } - .map { currency -> - CryptoCurrencyAddress( - cryptoCurrency = currency, - address = getDefaultAddress(userWalletId, network).orEmpty(), - ) - } + return runSuspendCatching { + cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network) + }.getOrElse { error -> + TangemLogger.e("Unable to create wallet currencies", error) + return emptyList() + }.map { currency -> + CryptoCurrencyAddress( + cryptoCurrency = currency, + address = getDefaultAddress(userWalletId, network).orEmpty(), + ) + } } override suspend fun getNetworkAddresses( userWalletId: UserWalletId, network: Network.RawID, ): List { - return runCatching { cardCryptoCurrencyFactory.createByRawId(userWalletId = userWalletId, network = network) } - .getOrElse { error -> - TangemLogger.e("Unable to create wallet currencies", error) - return emptyList() - } - .map { currency -> - CryptoCurrencyAddress( - cryptoCurrency = currency, - address = getDefaultAddress(userWalletId, currency.network).orEmpty(), - ) - } + return runSuspendCatching { + cardCryptoCurrencyFactory.createByRawId(userWalletId = userWalletId, network = network) + }.getOrElse { error -> + TangemLogger.e("Unable to create wallet currencies", error) + return emptyList() + }.map { currency -> + CryptoCurrencyAddress( + cryptoCurrency = currency, + address = getDefaultAddress(userWalletId, currency.network).orEmpty(), + ) + } } override suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? { diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt index 05b161810f..a6c6cf93dc 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStore.kt @@ -2,6 +2,7 @@ package com.tangem.data.networks.store import android.content.Context import androidx.datastore.core.DataStore +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.networks.converters.NetworkStatusDataModelConverter import com.tangem.data.networks.converters.SimpleNetworkStatusConverter import com.tangem.data.networks.models.SimpleNetworkStatus @@ -27,15 +28,15 @@ internal typealias WalletIdWithStatusDM = Map> * Default implementation of [NetworksStatusesStore] * * @param context context + * @param scope app coroutine scope * @property runtimeStore runtime store * @property persistenceDataStore persistence store - * @param dispatchers dispatchers */ internal class DefaultNetworksStatusesStore( context: Context, + scope: AppCoroutineScope, private val runtimeStore: RuntimeSharedStore, private val persistenceDataStore: DataStore, - private val scope: AppCoroutineScope, ) : NetworksStatusesStore { init { @@ -112,7 +113,8 @@ internal class DefaultNetworksStatusesStore( storedStatuses.toMutableMap().apply { val updatedValues = this[userWalletId.stringValue].orEmpty().filterNot { networks.any { network -> - it.networkId.value == network.rawId && it.derivationPath.value == network.derivationPath.value + it.networkId.value == network.toBlockchain().id && + it.derivationPath.value == network.derivationPath.value } } diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt index 5bbe4daf2d..5175a980de 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt @@ -16,10 +16,14 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkAmountsConverterTest { - private val rawNetworkId = "ETH" + // Cache stores the SDK-level Blockchain.id (legacy format, e.g. "ETH"). + // The converter normalizes it to the canonical network rawId ("ethereum") via + // Blockchain.fromId(...).toNetworkId() so that resulting CryptoCurrency.IDs match those + // built at runtime from Network.rawId. + private val blockchainId = "ETH" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = NetworkAmountsConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath) + private val converter = NetworkAmountsConverter(blockchainId = blockchainId, derivationPath = derivationPath) @Test fun convert() { @@ -47,12 +51,12 @@ internal class NetworkAmountsConverterTest { // Assert val expected = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.ZERO), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.TEN), ) @@ -63,12 +67,12 @@ internal class NetworkAmountsConverterTest { fun convertBack() { // Arrange val value = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.ZERO), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.TEN), ) diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt similarity index 63% rename from data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt rename to data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt index 9cdcc4810e..f1c72465ca 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest @@ -13,12 +14,16 @@ import org.junit.jupiter.params.ParameterizedTest [REDACTED_AUTHOR] */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CurrencyIdConverterTest { +class NetworkCurrencyIdConverterTest { - private val rawNetworkId = "ETH" + // Legacy SDK format stored in cache (see NetworkStatusDataModelConverter: + // `value.network.toBlockchain().id`). Runtime CryptoCurrency.ID expects the canonical + // network rawId ("ethereum"), so the converter normalizes via Blockchain.fromId(...).toNetworkId(). + private val blockchainId = "ETH" + private val canonicalNetworkRawId = "ethereum" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = CurrencyIdConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath) + private val converter = NetworkCurrencyIdConverter(blockchainId = blockchainId, derivationPath = derivationPath) @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -48,19 +53,19 @@ class CurrencyIdConverterTest { ConvertModel( value = CurrencyId.createCoinId("ethereum"), expected = Result.success( - CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩ethereum"), + CryptoCurrency.ID.fromValue(value = "coin⟨ethereum→$derivationPathHashCode⟩ethereum"), ), ), ConvertModel( value = CurrencyId.createCoinId(""), expected = Result.failure( - IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"), + IllegalStateException("Coin id is null for $blockchainId with $derivationPath"), ), ), ConvertModel( value = CurrencyId.createCoinId(" "), expected = Result.failure( - IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"), + IllegalStateException("Coin id is null for $blockchainId with $derivationPath"), ), ), // create token id @@ -71,7 +76,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -82,7 +87,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -93,7 +98,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -104,7 +109,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -114,7 +119,7 @@ class CurrencyIdConverterTest { contractAddress = "", ), expected = Result.success( - CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"), + CryptoCurrency.ID.fromValue(value = "coin⟨ethereum→$derivationPathHashCode⟩usdt"), ), ), ConvertModel( @@ -123,7 +128,7 @@ class CurrencyIdConverterTest { contractAddress = " ", ), expected = Result.success( - CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"), + CryptoCurrency.ID.fromValue(value = "coin⟨ethereum→$derivationPathHashCode⟩usdt"), ), ), ) @@ -154,14 +159,14 @@ class CurrencyIdConverterTest { private fun provideTestModels(): Collection = listOf( ConvertBackModel( - value = CryptoCurrency.ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum"), + value = CryptoCurrency.ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum"), expected = Result.success( CurrencyId.createCoinId("ethereum"), ), ), ConvertBackModel( value = CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ), expected = Result.success( CurrencyId.createTokenId( @@ -172,7 +177,7 @@ class CurrencyIdConverterTest { ), ConvertBackModel( value = CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), expected = Result.success( CurrencyId.createTokenId( @@ -184,6 +189,54 @@ class CurrencyIdConverterTest { ) } + /** + * Regression coverage for [REDACTED_TASK_KEY]. Cache stores `blockchainId` in the legacy SDK format + * (`Blockchain.id`, e.g. "ETH"), but runtime [CryptoCurrency.ID] is built using the canonical + * network rawId (`Blockchain.toNetworkId()`, e.g. "ethereum"). The converter must bridge the + * two formats so that IDs reconstructed from cache equal those built at runtime — otherwise + * `NetworkStatus.Verified.amounts[currency.id]` returns null and the wallet shimmer never clears. + */ + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class LegacyBlockchainIdNormalization { + + @Test + fun `convert with legacy ETH blockchainId produces id with canonical ethereum rawId`() { + val cached = CurrencyId.createCoinId("ethereum") + + val result = converter.convert(cached) + + Truth.assertThat(result) + .isEqualTo(CryptoCurrency.ID.fromValue("coin⟨$canonicalNetworkRawId→$derivationPathHashCode⟩ethereum")) + } + + @Test + fun `convert with legacy BTC blockchainId produces id with canonical bitcoin rawId`() { + val btcDerivationPath = Network.DerivationPath.Card(value = "m/44'/0'/0'/0/0") + val btcDerivationHash = btcDerivationPath.value.hashCode() + val btcConverter = NetworkCurrencyIdConverter( + blockchainId = "BTC", + derivationPath = btcDerivationPath, + ) + val cached = CurrencyId.createCoinId("bitcoin") + + val result = btcConverter.convert(cached) + + Truth.assertThat(result) + .isEqualTo(CryptoCurrency.ID.fromValue("coin⟨bitcoin→$btcDerivationHash⟩bitcoin")) + } + + @Test + fun `convert and convertBack roundtrip preserves CurrencyId`() { + val cached = CurrencyId.createCoinId("ethereum") + + val runtimeId = converter.convert(cached) + val roundTrip = converter.convertBack(runtimeId) + + Truth.assertThat(roundTrip).isEqualTo(cached) + } + } + data class ConvertModel(val value: CurrencyId, val expected: Result) data class ConvertBackModel(val value: CryptoCurrency.ID, val expected: Result) diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt index 97265f47a1..a667da490a 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.datasource.local.network.entity.NetworkStatusDM.* @@ -49,7 +50,7 @@ internal class NetworkStatusDataModelConverterTest { ), ), amounts = mapOf( - ID.fromValue(value = "coin⟨ETH→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue(value = "coin⟨ethereum→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), ID( prefix = Prefix.COIN_PREFIX, body = Body.NetworkId(rawId = "BTC"), @@ -74,7 +75,7 @@ internal class NetworkStatusDataModelConverterTest { ), ), expected = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "", type = DerivationPath.Type.NONE, @@ -119,7 +120,7 @@ internal class NetworkStatusDataModelConverterTest { ), ), expected = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "", type = DerivationPath.Type.NONE, diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt index af36491079..53e5bc65a6 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt @@ -13,10 +13,14 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkYieldSupplyStatusConverterTest { - private val rawNetworkId = "ETH" + // Cache stores the SDK-level Blockchain.id (legacy format, e.g. "ETH"). + // The converter normalizes it to the canonical network rawId ("ethereum") via + // Blockchain.fromId(...).toNetworkId() so that resulting CryptoCurrency.IDs match those + // built at runtime from Network.rawId. + private val blockchainId = "ETH" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = NetworkYieldSupplyStatusConverter(rawNetworkId, derivationPath) + private val converter = NetworkYieldSupplyStatusConverter(blockchainId, derivationPath) private val domainStatus = YieldSupplyStatus( isActive = true, @@ -38,8 +42,8 @@ internal class NetworkYieldSupplyStatusConverterTest { // Assert val expected = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus, - ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to domainStatus, + ID.fromValue("token⟨ethereum→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, ) Truth.assertThat(actual).containsExactlyEntriesIn(expected) @@ -49,9 +53,9 @@ internal class NetworkYieldSupplyStatusConverterTest { fun convertBack() { // Arrange val value = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus, - ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, - ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdc⚓0x1") to null, + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to domainStatus, + ID.fromValue("token⟨ethereum→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, + ID.fromValue("token⟨ethereum→$derivationPathHashCode⟩usdc⚓0x1") to null, ) // Act diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index 1b57d19996..d28e1c3f28 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.datasource.local.network.entity.NetworkStatusDM @@ -48,7 +49,7 @@ internal class SimpleNetworkStatusConverterTest { // region Verified ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -95,12 +96,12 @@ internal class SimpleNetworkStatusConverterTest { ), ), amounts = mapOf( - ID.fromValue("coin⟨ETH→3046160⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), - ID.fromValue("token⟨ETH→3046160⟩usdt⚓0x1") to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue("coin⟨ethereum→3046160⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue("token⟨ethereum→3046160⟩usdt⚓0x1") to Amount.Loaded(value = BigDecimal.ZERO), ), pendingTransactions = emptyMap(), yieldSupplyStatuses = mapOf( - ID.fromValue("coin⟨ETH→3046160⟩ethereum") to YieldSupplyStatus( + ID.fromValue("coin⟨ethereum→3046160⟩ethereum") to YieldSupplyStatus( isActive = false, isInitialized = false, isAllowedToSpend = false, @@ -116,7 +117,7 @@ internal class SimpleNetworkStatusConverterTest { // region NoAccount ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -168,7 +169,7 @@ internal class SimpleNetworkStatusConverterTest { // region Error ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -189,7 +190,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -205,7 +206,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -230,7 +231,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -255,7 +256,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -276,7 +277,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, diff --git a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt index e6f9bf012d..a36840920e 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt @@ -52,7 +52,7 @@ internal class CommonNetworkStatusFetcherTest { val userWalletId = UserWalletId("011") val network = cryptoCurrencyFactory.ethereum.network val extraTokens = setOf( - cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token, + cryptoCurrencyFactory.createToken(Blockchain.Ethereum), ) val updateException = IllegalStateException() @@ -80,7 +80,7 @@ internal class CommonNetworkStatusFetcherTest { val userWalletId = UserWalletId("011") val network = cryptoCurrencyFactory.ethereum.network val extraTokens = setOf( - cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token, + cryptoCurrencyFactory.createToken(Blockchain.Ethereum), ) val updateResult = model.updateResult val status = model.status @@ -143,15 +143,15 @@ internal class CommonNetworkStatusFetcherTest { it.copy( amounts = mapOf( CryptoCurrency.ID.fromValue( - value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND", + value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND", ) to NetworkStatus.Amount.NotFound, ), pendingTransactions = mapOf( - CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND") to emptySet(), + CryptoCurrency.ID.fromValue(value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND") to emptySet(), ), yieldSupplyStatuses = mapOf( CryptoCurrency.ID.fromValue( - value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND", + value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND", ) to null, ), ) diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt index f02bcd3cd3..f355cc27c1 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.networks.multi import arrow.core.Either import arrow.core.left import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.store.NetworksStatusesStore @@ -27,17 +28,21 @@ internal class DefaultMultiNetworkStatusFetcherTest { private val networksStatusesStore: NetworksStatusesStore = mockk(relaxUnitFun = true) private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher = mockk() + private val dynamicAddressesInitializer: DynamicAddressesInitializer = mockk() private val fetcher = DefaultMultiNetworkStatusFetcher( networksStatusesStore = networksStatusesStore, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, commonNetworkStatusFetcher = commonNetworkStatusFetcher, + dynamicAddressesInitializer = dynamicAddressesInitializer, dispatchers = TestingCoroutineDispatcherProvider(), ) @BeforeEach fun resetMocks() { - clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher) + clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher, dynamicAddressesInitializer) + // No dynamic addresses restore by default + coEvery { dynamicAddressesInitializer.getXpubs(any(), any()) } returns emptyMap() } @Test @@ -62,6 +67,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) } returns ethereumFetcherResult @@ -70,6 +76,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } returns cardanoFetcherResult @@ -87,11 +94,13 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } @@ -122,6 +131,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) } returns ethereumFetcherResult @@ -130,6 +140,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } returns cardanoFetcherResult @@ -147,11 +158,13 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } @@ -182,6 +195,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) } returns ethereumFetcherResult @@ -190,6 +204,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } returns cardanoFetcherResult @@ -207,11 +222,13 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } @@ -245,7 +262,220 @@ internal class DefaultMultiNetworkStatusFetcherTest { networksStatusesStore.setSourceAsOnlyCache(params.userWalletId, params.networks) } - coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any()) } + coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any(), any()) } + } + + @Test + fun `fetch passes xpub to correct network and null to others`() = runTest { + // Arrange + val xpub = "xpub_test_eth" + val params = setupTwoNetworkParams() + coEvery { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) } returns mapOf(ethereum.network to xpub) + + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = xpub, + ) + } returns Either.Right(Unit) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } returns Either.Right(Unit) + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Right(Unit) + assertEither(actual, expected) + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = xpub, + ) + } + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } + } + + @Test + fun `fetch continues with null xpub for all networks if getXpubs throws`() = runTest { + // Arrange + val params = setupTwoNetworkParams() + coEvery { + dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) + } throws RuntimeException("XPUB derivation failed") + + val fetchResult = Either.Right(Unit) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = null, + ) + } returns fetchResult + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } returns fetchResult + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Right(Unit) + assertEither(actual, expected) + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = null, + ) + } + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } + // getXpubs failure must not degrade network status to OnlyCache + coVerify(inverse = true) { networksStatusesStore.setSourceAsOnlyCache(userWalletId = any(), networks = any()) } + } + + @Test + fun `fetch passes xpubs to all networks returned by getXpubs`() = runTest { + // Arrange + val ethXpub = "xpub_eth" + val adaXpub = "xpub_ada" + val params = setupTwoNetworkParams() + coEvery { + dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) + } returns mapOf(ethereum.network to ethXpub, cardano.network to adaXpub) + + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = ethXpub, + ) + } returns Either.Right(Unit) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = adaXpub, + ) + } returns Either.Right(Unit) + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Right(Unit) + assertEither(actual, expected) + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = ethXpub, + ) + } + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = adaXpub, + ) + } + } + + @Test + fun `fetch calls getXpubs with exactly the networks from params`() = runTest { + // Arrange + val params = setupTwoNetworkParams() + coEvery { + commonNetworkStatusFetcher.fetch(userWalletId = any(), network = any(), networkCurrencies = any(), xpub = any()) + } returns Either.Right(Unit) + + // Act + fetcher(params) + + // Assert + coVerify(exactly = 1) { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) } + } + + @Test + fun `fetch failure if network fetch fails when xpub is provided`() = runTest { + // Arrange + val xpub = "xpub_eth" + val params = setupTwoNetworkParams() + coEvery { + dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) + } returns mapOf(ethereum.network to xpub) + + val fetchFailure = Either.Left(IllegalStateException()) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = xpub, + ) + } returns fetchFailure + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } returns Either.Right(Unit) + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Left(IllegalStateException("Failed to fetch network statuses")) + assertEither(actual, expected) + } + + private fun setupTwoNetworkParams(): MultiNetworkStatusFetcher.Params { + val params = MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = setOf(ethereum.network, cardano.network), + ) + coEvery { cardCryptoCurrencyFactory.create(params.userWalletId, params.networks) } returns mapOf( + ethereum.network to listOf(ethereum), + cardano.network to listOf(cardano), + ) + return params } private companion object { diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 293cc63837..7ec5280c72 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -26,6 +26,7 @@ import com.tangem.domain.news.repository.NewsRepository import com.tangem.pagination.* import com.tangem.pagination.exception.EndOfPaginationException import com.tangem.pagination.fetcher.BatchFetcher +import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger @@ -47,6 +48,9 @@ internal class DefaultNewsRepository( private val newsErrorResolver: NewsErrorResolver, ) : NewsRepository { + private val language: String + get() = SupportedLanguages.getCurrentSupportedLanguageCode() + override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { val newsBatchFlow = BatchListSource( fetchDispatcher = dispatchers.io, @@ -63,7 +67,7 @@ internal class DefaultNewsRepository( val items = newsApi.getNews( page = FIRST_PAGE, limit = limit, - language = config.language, + language = language, snapshot = config.snapshot, tokenIds = config.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, @@ -105,12 +109,10 @@ internal class DefaultNewsRepository( } } - override suspend fun fetchDetailedArticles( - newsIds: Collection, - language: String?, - ): Either, Unit> = fetchDetailedArticlesInternal(newsIds = newsIds, language = language) + override suspend fun fetchDetailedArticles(newsIds: Collection): Either, Unit> = + fetchDetailedArticlesInternal(newsIds = newsIds, language = language) - override suspend fun fetchTrendingNews(limit: Int, language: String?) { + override suspend fun fetchTrendingNews(limit: Int) { fetchAndStoreTrendingNews(limit = limit, language = language) } @@ -259,7 +261,7 @@ internal class DefaultNewsRepository( ) } - private class NewsBatchFetcher( + private inner class NewsBatchFetcher( private val newsApi: NewsApi, private val batchSize: Int, private val newsViewedStore: NewsViewedStore, @@ -326,7 +328,7 @@ internal class DefaultNewsRepository( val response = newsApi.getNews( page = page, limit = limit, - language = params.language, + language = language, snapshot = snapshotOverride?.takeIf { it.isNotEmpty() }, tokenIds = params.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index 3baa26a3f4..d5e0ee4f95 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -568,7 +568,7 @@ internal class DefaultNFTRepository @Inject constructor( private fun Network.canHandleNFTs(userWalletId: UserWalletId): Boolean { val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - val blockchain = Blockchain.fromNetworkId(backendId) ?: return false + val blockchain = Blockchain.fromNetworkId(rawId) ?: return false return blockchain.canHandleNFTs() && userWallet.canHandleToken(blockchain, excludedBlockchains) diff --git a/data/notifications/build.gradle.kts b/data/notifications/build.gradle.kts index a54a8c6725..21476b24c2 100644 --- a/data/notifications/build.gradle.kts +++ b/data/notifications/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) implementation(projects.core.ui) + implementation(projects.common.ui) implementation(projects.libs.blockchainSdk) // endregion diff --git a/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt b/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt index 403718705e..77a962a417 100644 --- a/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt +++ b/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt @@ -2,7 +2,7 @@ package com.tangem.data.notifications.converters import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.datasource.api.tangemTech.models.CryptoNetworkResponse import com.tangem.domain.notifications.models.NotificationsEligibleNetwork @@ -13,7 +13,7 @@ internal object NotificationsEligibleNetworkConverter { id = value.networkId, name = blockchain.fullName, symbol = blockchain.currency, - icon = getActiveIconRes(blockchain.id), + icon = getActiveIconRes(blockchain), ) } } \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index 7fe880aa2b..7ab90c6574 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -249,7 +249,7 @@ internal class DefaultOnrampRepository( to = listOf( OnrampDestinationDTO( contractAddress = cryptoCurrency.getContractAddress(), - network = cryptoCurrency.network.backendId, + network = cryptoCurrency.network.rawId, ), ), ), @@ -314,7 +314,7 @@ internal class DefaultOnrampRepository( to = listOf( OnrampDestinationDTO( contractAddress = cryptoCurrency.getContractAddress(), - network = cryptoCurrency.network.backendId, + network = cryptoCurrency.network.rawId, ), ), ), @@ -363,7 +363,7 @@ internal class DefaultOnrampRepository( fromCurrencyCode = currency.code, fromPrecision = currency.precision, toContractAddress = cryptoCurrency.getContractAddress(), - toNetwork = cryptoCurrency.network.backendId, + toNetwork = cryptoCurrency.network.rawId, paymentMethod = paymentMethod.id, countryCode = country.code, fromAmount = fromAmount, @@ -436,7 +436,7 @@ internal class DefaultOnrampRepository( fromCurrencyCode = currency.code, fromPrecision = currency.precision, toContractAddress = cryptoCurrency.getContractAddress(), - toNetwork = cryptoCurrency.network.backendId, + toNetwork = cryptoCurrency.network.rawId, paymentMethod = quote.paymentMethod.id, countryCode = country.code, fromAmount = fromAmountString, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt index cac610a9e7..19478a403d 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -176,7 +176,7 @@ internal class Bip321PaymentUriParserTest { @Test fun `includes tokens on matching network`() { - val btcToken = buildToken("BTC", "RUNE", "contractAddr") + val btcToken = buildToken("bitcoin", "RUNE", "contractAddr") val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01", @@ -263,7 +263,13 @@ internal class Bip321PaymentUriParserTest { @Test fun `memo on network with memo support is not unsupported`() { - val xrpCoin = buildCoin("XRP", "XRP", "XRP", decimals = 6, extrasType = Network.TransactionExtrasType.DESTINATION_TAG) + val xrpCoin = buildCoin( + rawNetworkId = "xrp", + name = "XRP", + symbol = "XRP", + decimals = 6, + extrasType = Network.TransactionExtrasType.DESTINATION_TAG, + ) val result = parser.parse( qrCode = "ripple:rAddress?dt=12345", @@ -299,9 +305,9 @@ internal class Bip321PaymentUriParserTest { } } - private val bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8) - private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8) - private val dogecoinCoin = buildCoin("DOGE", "Dogecoin", "DOGE", decimals = 8) + private val bitcoinCoin = buildCoin("bitcoin", "Bitcoin", "BTC", decimals = 8) + private val litecoinCoin = buildCoin("litecoin", "Litecoin", "LTC", decimals = 8) + private val dogecoinCoin = buildCoin("dogecoin", "Dogecoin", "DOGE", decimals = 8) private fun buildCoin( rawNetworkId: String, @@ -349,8 +355,7 @@ internal class Bip321PaymentUriParserTest { extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE, ): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = name, currencySymbol = symbol, derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt index 3aecfcfbfa..4321199575 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt @@ -1,7 +1,6 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth -import com.tangem.blockchain.common.Blockchain import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.models.currency.CryptoCurrency @@ -78,7 +77,8 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testBip021() { - every { network.id.rawId.value } returns Blockchain.Bitcoin.id + every { network.id } returns Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None) + every { network.rawId } returns "bitcoin" positiveCase( "$schema1:$address1", QrResult(address = address1), @@ -128,7 +128,8 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testErc681Coin() { - every { network.id.rawId.value } returns Blockchain.Ethereum.id + every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) + every { network.rawId } returns "ethereum" positiveCase( address2, QrResult(address = address2), @@ -183,7 +184,8 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testErc681Token() { - every { network.id.rawId.value } returns Blockchain.Ethereum.id + every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) + every { network.rawId } returns "ethereum" positiveCase( address2, QrResult(address = address2), diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index a98cbe0026..7dba3a75fe 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -382,8 +382,7 @@ internal class Eip681PaymentUriParserTest { private fun buildNetwork(rawNetworkId: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt index af88722a8d..d1259b8904 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -233,8 +233,7 @@ internal class QrContentClassifierTest { private fun buildNetwork(rawNetworkId: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt index 068663caed..4c692cc027 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt @@ -198,13 +198,13 @@ internal class SolanaPaymentUriParserTest { } } - private val solanaNetwork = buildNetwork("SOLANA", "Solana", "SOL") + private val solanaNetwork = buildNetwork("solana", "Solana", "SOL") private val solanaCoin = CryptoCurrency.Coin( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), - suffix = CryptoCurrency.ID.Suffix.RawID("SOLANA"), + body = CryptoCurrency.ID.Body.NetworkId("solana"), + suffix = CryptoCurrency.ID.Suffix.RawID("solana"), ), network = solanaNetwork, name = "Solana", @@ -217,7 +217,7 @@ internal class SolanaPaymentUriParserTest { private val usdcToken = CryptoCurrency.Token( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), + body = CryptoCurrency.ID.Body.NetworkId("solana"), suffix = CryptoCurrency.ID.Suffix.RawID("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), ), network = solanaNetwork, @@ -231,8 +231,7 @@ internal class SolanaPaymentUriParserTest { private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = name, currencySymbol = symbol, derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt index 8c07742046..64bab29d5c 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt @@ -238,13 +238,13 @@ internal class TronPaymentUriParserTest { } } - private val tronNetwork = buildNetwork("TRON", "Tron", "TRX") + private val tronNetwork = buildNetwork("tron", "Tron", "TRX") private val tronCoin = CryptoCurrency.Coin( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("TRON"), - suffix = CryptoCurrency.ID.Suffix.RawID("TRON"), + body = CryptoCurrency.ID.Body.NetworkId("tron"), + suffix = CryptoCurrency.ID.Suffix.RawID("tron"), ), network = tronNetwork, name = "Tron", @@ -257,7 +257,7 @@ internal class TronPaymentUriParserTest { private val usdtToken = CryptoCurrency.Token( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("TRON"), + body = CryptoCurrency.ID.Body.NetworkId("tron"), suffix = CryptoCurrency.ID.Suffix.RawID("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), ), network = tronNetwork, @@ -271,8 +271,7 @@ internal class TronPaymentUriParserTest { private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = name, currencySymbol = symbol, derivationPath = Network.DerivationPath.None, diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts index 3b71894edc..5bae11084f 100644 --- a/data/settings/build.gradle.kts +++ b/data/settings/build.gradle.kts @@ -13,6 +13,10 @@ android { namespace = "com.tangem.data.settings" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { implementation(projects.core.datasource) @@ -28,6 +32,11 @@ dependencies { kapt(deps.hilt.kapt) // endregion + // region Test + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) + // endregion + // region Others dependencies implementation(deps.jodatime) implementation(deps.kotlin.coroutines) diff --git a/data/settings/src/main/java/com/tangem/data/settings/DevHotWalletRestrictionManager.kt b/data/settings/src/main/java/com/tangem/data/settings/DevHotWalletRestrictionManager.kt new file mode 100644 index 0000000000..87b20c09a8 --- /dev/null +++ b/data/settings/src/main/java/com/tangem/data/settings/DevHotWalletRestrictionManager.kt @@ -0,0 +1,56 @@ +package com.tangem.data.settings + +import androidx.datastore.preferences.core.booleanPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.domain.settings.HotWalletRestrictionManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** + * Development implementation of [HotWalletRestrictionManager]. + * + * Reads and writes the restriction state from [AppPreferencesStore], + * allowing testers to toggle it via the Tester Menu. + * The preference [Flow] is converted to a [StateFlow] on construction, + * so [isCreationEnabledSync] can be called from non-suspending contexts. + * Defaults to `true` (restriction enabled) when no value is stored. + */ +internal class DevHotWalletRestrictionManager( + private val appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, +) : HotWalletRestrictionManager { + + private val isCreationEnabledState: StateFlow = + appPreferencesStore + .get(key = IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY, default = true) + .stateIn( + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + started = SharingStarted.Eagerly, + initialValue = true, + ) + + override fun isCreationEnabled(): StateFlow = isCreationEnabledState + + override fun isCreationEnabledSync(): Boolean = isCreationEnabledState.value + + override suspend fun toggleCreationEnabled() { + appPreferencesStore.editData { preferences -> + val isEnabled = preferences.getOrDefault( + key = IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY, + default = true, + ) + preferences[IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY] = !isEnabled + } + } + + private companion object { + val IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY = + booleanPreferencesKey(name = "isHotWalletCreationRestrictionEnabled") + } +} \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/ProdHotWalletRestrictionManager.kt b/data/settings/src/main/java/com/tangem/data/settings/ProdHotWalletRestrictionManager.kt new file mode 100644 index 0000000000..0626befff2 --- /dev/null +++ b/data/settings/src/main/java/com/tangem/data/settings/ProdHotWalletRestrictionManager.kt @@ -0,0 +1,21 @@ +package com.tangem.data.settings + +import com.tangem.domain.settings.HotWalletRestrictionManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Production implementation of [HotWalletRestrictionManager]. + * + * Hot wallet creation restriction is always enabled in production builds — + * users must scan a physical Tangem card to add a wallet. + * [toggleCreationEnabled] is a no-op since the restriction cannot be changed at runtime. + */ +internal class ProdHotWalletRestrictionManager : HotWalletRestrictionManager { + + private val state: StateFlow = MutableStateFlow(true) + + override fun isCreationEnabled(): StateFlow = state + override fun isCreationEnabledSync(): Boolean = true + override suspend fun toggleCreationEnabled() = Unit +} \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt index 8728678fe4..ebc5c40001 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt @@ -1,12 +1,16 @@ package com.tangem.data.settings.di import android.content.Context +import com.tangem.data.settings.BuildConfig import com.tangem.data.settings.DefaultAppRatingRepository import com.tangem.data.settings.DefaultPermissionRepository import com.tangem.data.settings.DefaultSettingsRepository +import com.tangem.data.settings.DevHotWalletRestrictionManager +import com.tangem.data.settings.ProdHotWalletRestrictionManager import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.PermissionRepository import com.tangem.domain.settings.repositories.SettingsRepository @@ -55,4 +59,17 @@ internal object SettingsDataModule { context = context, ) } + + @Provides + @Singleton + fun provideHotWalletRestrictionManager( + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): HotWalletRestrictionManager { + return if (BuildConfig.TESTER_MENU_ENABLED) { + DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + } else { + ProdHotWalletRestrictionManager() + } + } } \ No newline at end of file diff --git a/data/settings/src/test/java/com/tangem/data/settings/DevHotWalletRestrictionManagerTest.kt b/data/settings/src/test/java/com/tangem/data/settings/DevHotWalletRestrictionManagerTest.kt new file mode 100644 index 0000000000..1cb2dacb6e --- /dev/null +++ b/data/settings/src/test/java/com/tangem/data/settings/DevHotWalletRestrictionManagerTest.kt @@ -0,0 +1,99 @@ +package com.tangem.data.settings + +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.test.core.getEmittedValues +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DevHotWalletRestrictionManagerTest { + + private val appPreferencesStore = mockk(relaxed = true) + private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider() + + @BeforeEach + fun setup() { + mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + } + + @AfterEach + fun tearDown() { + unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + } + + @Test + fun `GIVEN preference is true WHEN isCreationEnabled THEN emits true`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(true) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + val emitted = getEmittedValues(manager.isCreationEnabled()) + + assertThat(emitted).containsExactly(true) + } + + @Test + fun `GIVEN preference is false WHEN isCreationEnabled THEN emits false`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(false) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + val emitted = getEmittedValues(manager.isCreationEnabled()) + + assertThat(emitted).containsExactly(false) + } + + @Test + fun `GIVEN preference Flow emits true WHEN isCreationEnabledSync THEN returns cached true`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(true) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + assertThat(manager.isCreationEnabledSync()).isTrue() + } + + @Test + fun `GIVEN preference Flow emits false WHEN isCreationEnabledSync THEN returns cached false`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(false) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + assertThat(manager.isCreationEnabledSync()).isFalse() + } + + @Test + fun `WHEN toggleCreationEnabled THEN opens editData transaction`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(true) + coEvery { appPreferencesStore.editData(any()) } returns mockk(relaxed = true) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + manager.toggleCreationEnabled() + + coVerify { appPreferencesStore.editData(any()) } + } +} \ No newline at end of file diff --git a/data/settings/src/test/java/com/tangem/data/settings/ProdHotWalletRestrictionManagerTest.kt b/data/settings/src/test/java/com/tangem/data/settings/ProdHotWalletRestrictionManagerTest.kt new file mode 100644 index 0000000000..32ca85becf --- /dev/null +++ b/data/settings/src/test/java/com/tangem/data/settings/ProdHotWalletRestrictionManagerTest.kt @@ -0,0 +1,31 @@ +package com.tangem.data.settings + +import com.google.common.truth.Truth.assertThat +import com.tangem.test.core.getEmittedValues +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ProdHotWalletRestrictionManagerTest { + + private val manager = ProdHotWalletRestrictionManager() + + @Test + fun `WHEN isCreationEnabled THEN always emits true`() = runTest { + val emitted = getEmittedValues(manager.isCreationEnabled()) + + assertThat(emitted).containsExactly(true) + } + + @Test + fun `WHEN isCreationEnabledSync THEN returns true`() = runTest { + assertThat(manager.isCreationEnabledSync()).isTrue() + } + + @Test + fun `WHEN toggleCreationEnabled THEN isCreationEnabledSync still returns true`() = runTest { + manager.toggleCreationEnabled() + assertThat(manager.isCreationEnabledSync()).isTrue() + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index b189675a38..1403dcdaf9 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -65,7 +66,7 @@ internal class DefaultP2PEthPoolRepository( } override suspend fun fetchVaults(network: P2PEthPoolNetwork) { - val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { + val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) { getVaults(network).getOrElse { error -> TangemLogger.e("Error fetching P2PEthPool vaults: $error") emptyList() diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt index 79dc265b4f..335d1a5dac 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionStatus import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toCompressedPublicKey import com.tangem.data.staking.converters.YieldConverter @@ -244,7 +245,7 @@ internal class DefaultStakeKitRepository( } override suspend fun constructTransaction( - networkId: String, + networkId: Network.RawID, fee: Fee, amount: Amount, transactionId: String, @@ -320,8 +321,11 @@ internal class DefaultStakeKitRepository( } } - private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data { - return when (Blockchain.fromId(networkId)) { + private fun getTransactionDataType( + networkId: Network.RawID, + unsignedTransaction: String, + ): TransactionData.Compiled.Data { + return when (val blockchain = networkId.toBlockchain()) { Blockchain.Solana, Blockchain.Cosmos, -> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes()) @@ -335,7 +339,7 @@ internal class DefaultStakeKitRepository( ?: error("Failed to parse Tron StakeKit transaction") TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex) } - else -> error("Unsupported blockchain") + else -> error("Unsupported blockchain: $blockchain") } } @@ -411,7 +415,7 @@ internal class DefaultStakeKitRepository( } private fun getTronResource(network: Network): TronResource? { - val blockchain = Blockchain.fromNetworkId(network.backendId) + val blockchain = Blockchain.fromNetworkId(network.rawId) return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) { TronResource.ENERGY diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index b46d6ea594..f98c982fa6 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -1,8 +1,6 @@ package com.tangem.data.staking import arrow.core.getOrElse -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.models.currency.CryptoCurrency @@ -22,14 +20,13 @@ import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flowOf + import kotlinx.coroutines.withContext -@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class DefaultStakingRepository( private val stakeKitRepository: StakeKitRepository, private val p2pEthPoolRepository: P2PEthPoolRepository, - private val stakingBalanceStoreV2: StakeKitBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, private val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, private val stakingFeatureToggles: StakingFeatureToggles, @@ -40,7 +37,8 @@ internal class DefaultStakingRepository( cryptoCurrency: CryptoCurrency, ): Flow { return channelFlow { - if (!checkFeatureToggleEnabled(cryptoCurrency)) { + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) + if (stakingIntegration == null || !stakingFeatureToggles.isIntegrationEnabled(stakingIntegration)) { send(StakingAvailability.Unavailable) return@channelFlow } @@ -56,8 +54,6 @@ internal class DefaultStakingRepository( return@channelFlow } - val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) - val availabilityFlow = when (stakingIntegration) { StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability() is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability( @@ -65,7 +61,6 @@ internal class DefaultStakingRepository( rawCurrencyId, cryptoCurrency.symbol, ) - null -> flowOf(StakingAvailability.Unavailable) } availabilityFlow.collect { send(it) } @@ -76,20 +71,15 @@ internal class DefaultStakingRepository( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): StakingAvailability { - if (!checkFeatureToggleEnabled(cryptoCurrency)) { - return StakingAvailability.Unavailable - } + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) + ?.takeIf(stakingFeatureToggles::isIntegrationEnabled) + ?: return StakingAvailability.Unavailable if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) { return StakingAvailability.Unavailable } val rawCurrencyId = cryptoCurrency.id.rawCurrencyId - if (rawCurrencyId == null) { - return StakingAvailability.Unavailable - } - - val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) ?: return StakingAvailability.Unavailable return when (stakingIntegration) { @@ -104,7 +94,7 @@ internal class DefaultStakingRepository( override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { return withContext(dispatchers.default) { - val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false + val balances = stakeKitBalancesStore.getAllSyncOrNull(userWalletId) ?: return@withContext false val hasDataStakingBalance by lazy { balances.any { stakingBalance -> @@ -116,18 +106,6 @@ internal class DefaultStakingRepository( } } - private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean { - return when (cryptoCurrency.network.id.toBlockchain()) { - Blockchain.Ethereum -> { - when (cryptoCurrency) { - is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled - is CryptoCurrency.Token -> true - } - } - else -> true - } - } - private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { val userWallet = getUserWalletUseCase(userWalletId).getOrElse { error("Failed to get user wallet") diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt index 4ddab5988c..8937c4ec40 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt @@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier import com.tangem.domain.staking.single.SingleStakingBalanceProducer import com.tangem.domain.staking.single.SingleStakingBalanceSupplier +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -54,7 +54,7 @@ internal object StakingBalanceSupplierModule { fun provideSingleStakingBalanceSupplier( factory: SingleStakingBalanceProducer.Factory, ): SingleStakingBalanceSupplier { - return object : SingleStakingBalanceSupplier( + return SingleStakingBalanceSupplier( factory = factory, keyCreator = { params -> listOf( @@ -65,15 +65,15 @@ internal object StakingBalanceSupplierModule { ) .joinToString(separator = "_") }, - ) {} + ) } @Provides @Singleton fun provideMultiStakingBalanceSupplier(factory: MultiStakingBalanceProducer.Factory): MultiStakingBalanceSupplier { - return object : MultiStakingBalanceSupplier( + return MultiStakingBalanceSupplier( factory = factory, keyCreator = { "multi_staking_balances_${it.userWalletId.stringValue}" }, - ) {} + ) } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 0aa8abf1b5..a997cbce26 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -65,7 +65,7 @@ internal object StakingDataModule { return DefaultStakingRepository( stakeKitRepository = stakeKitRepository, p2pEthPoolRepository = p2pEthPoolRepository, - stakingBalanceStoreV2 = stakeKitBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, dispatchers = dispatchers, getUserWalletUseCase = getUserWalletUseCase, stakingFeatureToggles = stakingFeatureToggles, diff --git a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt index 011cc73bf9..6f62c8bc06 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt @@ -2,12 +2,35 @@ package com.tangem.data.staking.toggles import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles internal class DefaultStakingFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) : StakingFeatureToggles { - override val isEthStakingEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) + override fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean { + val toggle = integrationId.getFeatureToggle() ?: return true + return featureTogglesManager.isFeatureEnabled(toggle) + } + + private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) { + is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED + is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle() + } + + private fun StakingIntegrationID.StakeKit.getStakeKitFeatureToggle(): FeatureToggles? = when (this) { + is StakingIntegrationID.StakeKit.Coin -> when (this) { + StakingIntegrationID.StakeKit.Coin.Ton, + StakingIntegrationID.StakeKit.Coin.Solana, + StakingIntegrationID.StakeKit.Coin.Cosmos, + StakingIntegrationID.StakeKit.Coin.Tron, + StakingIntegrationID.StakeKit.Coin.BSC, + StakingIntegrationID.StakeKit.Coin.Cardano, + -> null + } + is StakingIntegrationID.StakeKit.EthereumToken -> when (this) { + StakingIntegrationID.StakeKit.EthereumToken.Polygon -> null + } + } } \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt new file mode 100644 index 0000000000..ca6766c8c8 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt @@ -0,0 +1,61 @@ +package com.tangem.data.staking.toggles + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.staking.model.StakingIntegrationID +import com.google.common.truth.Truth.assertThat +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultStakingFeatureTogglesTest { + + private val featureTogglesManager: FeatureTogglesManager = mockk() + private val toggles = DefaultStakingFeatureToggles(featureTogglesManager = featureTogglesManager) + + @BeforeEach + fun resetMocks() { + clearMocks(featureTogglesManager) + } + + @Test + fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() { + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true + + assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue() + + verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } + } + + @Test + fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() { + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false + + assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse() + + verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } + } + + @Test + fun `existing StakeKit Coin integrations are always enabled`() { + StakingIntegrationID.StakeKit.Coin.entries.forEach { coin -> + assertThat(toggles.isIntegrationEnabled(coin)).isTrue() + } + + verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } + } + + @Test + fun `existing StakeKit EthereumToken integrations are always enabled`() { + StakingIntegrationID.StakeKit.EthereumToken.entries.forEach { token -> + assertThat(toggles.isIntegrationEnabled(token)).isTrue() + } + + verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } + } +} \ No newline at end of file diff --git a/data/swap/build.gradle.kts b/data/swap/build.gradle.kts index 8b9fdaffe8..6541bb22cd 100644 --- a/data/swap/build.gradle.kts +++ b/data/swap/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.data.swap" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Core */ implementation(projects.core.datasource) @@ -56,4 +60,10 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(tangemDeps.card.core) + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index a2c353ef57..a888483d9c 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -13,6 +13,7 @@ import com.tangem.data.swap.converter.TokenInfoConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.request.ExchangeSentRequestBody +import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.express.models.request.PairsRequestBody import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails import com.tangem.datasource.api.express.models.response.RateType @@ -34,7 +35,6 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.models.* -import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -44,6 +44,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import java.io.IOException import java.math.BigDecimal +import java.math.RoundingMode import java.util.UUID import javax.inject.Inject @@ -64,6 +65,76 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( private val exchangeStatusConverter = SwapStatusConverter() private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java) + override suspend fun getPairs( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ): List = withContext(coroutineDispatcher.default) { + val primaryCurrency = primarySwapCurrencyStatus.currency + val secondaryCurrency = secondarySwapCurrencyStatus.currency + val primaryUserWallet = primarySwapCurrencyStatus.userWallet + val secondaryUserWallet = secondarySwapCurrencyStatus.userWallet + + val pairs = awaitAll( + // original pairs + async { + invokePairRequest( + userWallet = primaryUserWallet, + from = listOf(primaryCurrency), + to = listOf(secondaryCurrency), + ) + }, + // reversed pairs + async { + invokePairRequest( + userWallet = secondaryUserWallet, + from = listOf(secondaryCurrency), + to = listOf(primaryCurrency), + ) + }, + ).flatten() + + val expressProviders = expressRepository.getFilteredProviders( + userWallet = primaryUserWallet, + filterProviderTypes = filterProviderTypes, + swapTxType = swapTxType, + ).associateBy(ExpressProvider::providerId) + + fun checkPair(currency: CryptoCurrency, pair: LeastTokenInfo): Boolean { + return currency.getContractAddress() == pair.contractAddress && + currency.network.rawId == pair.network + } + + pairs.mapNotNull { pair -> + val fromCurrencyStatus = when { + checkPair(primaryCurrency, pair.from) -> primarySwapCurrencyStatus.status + checkPair(secondaryCurrency, pair.from) -> secondarySwapCurrencyStatus.status + else -> null + } + + val toCurrencyStatus = when { + checkPair(primaryCurrency, pair.to) -> primarySwapCurrencyStatus.status + checkPair(secondaryCurrency, pair.to) -> secondarySwapCurrencyStatus.status + else -> null + } + + val mappedProviders = pair.providers + .mapNotNull { it.withExpressProvider(expressProviders) } + .filterYieldSupplyProvider(fromCurrencyStatus) + + if (fromCurrencyStatus != null && toCurrencyStatus != null && mappedProviders.isNotEmpty()) { + SwapPairModel( + from = fromCurrencyStatus, + to = toCurrencyStatus, + providers = mappedProviders, + ) + } else { + null + } + } + } + override suspend fun getPairs( userWallet: UserWallet, initialCurrency: CryptoCurrency, @@ -91,12 +162,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( val statusFrom = cryptoCurrencyStatusList .firstOrNull { currencyStatus -> currencyStatus.currency.getContractAddress() == pair.from.contractAddress && - currencyStatus.currency.network.backendId == pair.from.network + currencyStatus.currency.network.rawId == pair.from.network } val statusTo = cryptoCurrencyStatusList .firstOrNull { currencyStatus -> currencyStatus.currency.getContractAddress() == pair.to.contractAddress && - currencyStatus.currency.network.backendId == pair.to.network + currencyStatus.currency.network.rawId == pair.to.network } val mappedProviders = pair.providers @@ -143,14 +214,14 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( cryptoCurrencyList .firstOrNull { currency -> currency.getContractAddress() == pair.from.contractAddress && - currency.network.backendId == pair.from.network + currency.network.rawId == pair.from.network } } val statusToDeferred = async { cryptoCurrencyList .firstOrNull { currency -> currency.getContractAddress() == pair.to.contractAddress && - currency.network.backendId == pair.to.network + currency.network.rawId == pair.to.network } } @@ -185,23 +256,19 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ): SwapQuoteModel = withContext(coroutineDispatcher.io) { val response = tangemExpressApi.getExchangeQuote( fromAmount = if (amountType == SwapAmountType.From) { - amount.movePointRight( - fromCryptoCurrency.decimals, - ).toString() + amount.toStringWithRightOffset(fromCryptoCurrency.decimals) } else { null }, toAmount = if (amountType == SwapAmountType.To) { - amount.movePointRight( - toCryptoCurrency.decimals, - ).toString() + amount.toStringWithRightOffset(toCryptoCurrency.decimals) } else { null }, - fromNetwork = fromCryptoCurrency.network.backendId, + fromNetwork = fromCryptoCurrency.network.rawId, fromContractAddress = fromCryptoCurrency.getContractAddress(), fromDecimals = fromCryptoCurrency.decimals, - toNetwork = toCryptoCurrency.network.backendId, + toNetwork = toCryptoCurrency.network.rawId, toContractAddress = toCryptoCurrency.getContractAddress(), toDecimals = toCryptoCurrency.decimals, providerId = provider.providerId, @@ -229,7 +296,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toAddress: String, toExtraId: String?, @@ -255,14 +322,22 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( val response = tangemExpressApi.getExchangeData( fromContractAddress = fromCurrency.getContractAddress(), toContractAddress = toCryptoCurrency.getContractAddress(), - fromNetwork = fromCurrency.network.backendId, - toNetwork = toCryptoCurrency.network.backendId, + fromNetwork = fromCurrency.network.rawId, + toNetwork = toCryptoCurrency.network.rawId, fromAddress = fromStatus.networkAddress?.defaultAddress?.value.orEmpty(), toAddress = toAddress, fromDecimals = fromCurrency.decimals, toDecimals = toCryptoCurrency.decimals, - fromAmount = if (amountType == SwapAmountType.From) amount else null, - toAmount = if (amountType == SwapAmountType.To) amount else null, + fromAmount = if (amountType == SwapAmountType.From) { + amount.toStringWithRightOffset(fromCurrency.decimals) + } else { + null + }, + toAmount = if (amountType == SwapAmountType.To) { + amount.toStringWithRightOffset(toCryptoCurrency.decimals) + } else { + null + }, providerId = expressProvider.providerId, rateType = rateType.name.lowercase(), requestId = requestId, @@ -316,7 +391,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ), body = ExchangeSentRequestBody( txId = txId, - fromNetwork = currency.network.backendId, + fromNetwork = currency.network.rawId, fromAddress = status.networkAddress?.defaultAddress?.value.orEmpty(), payinAddress = payInAddress, payinExtraId = txExtraId, @@ -395,7 +470,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ).getOrThrow() }, onError = { error -> - TangemLogger.w("Unable to get pairs", error) + TangemLogger.e("Unable to get pairs", error) throw error }, ) @@ -465,6 +540,10 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( } } + private fun BigDecimal.toStringWithRightOffset(decimals: Int): String { + return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() + } + private fun List.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) = filter { provider -> // !!!WARNING!!! Filter out dex provider if yield supply is active diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt index 37c6ab2dfb..5816ec12b9 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt @@ -54,7 +54,8 @@ internal class DefaultSwapTransactionRepository( private val userTokensResponseFactory = UserTokensResponseFactory() override suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -75,7 +76,10 @@ internal class DefaultSwapTransactionRepository( val tokenTransactions = savedTransactions ?.firstOrNull { swapTxList -> swapTxList.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, + fromAccountId = fromAccount?.accountId, + toAccountId = toAccount?.accountId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) @@ -89,7 +93,8 @@ internal class DefaultSwapTransactionRepository( mutablePreferences.setObject( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, value = savedTransactions?.updateList( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -97,7 +102,8 @@ internal class DefaultSwapTransactionRepository( transactions = tokenTransactions, ) ?: listOf( listConverter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -123,7 +129,7 @@ internal class DefaultSwapTransactionRepository( ) { savedTransactions, txStatuses, multiAccountList -> val currencyTxs = savedTransactions ?.filter { swapTxList -> - swapTxList.userWalletId == userWallet.walletId.stringValue && + swapTxList.fromUserWalletId == userWallet.walletId.stringValue && ( swapTxList.toCryptoCurrencyId == cryptoCurrencyId.value || swapTxList.fromCryptoCurrencyId == cryptoCurrencyId.value @@ -222,19 +228,27 @@ internal class DefaultSwapTransactionRepository( } } + @Suppress("LongParameterList") private fun SwapTransactionListDTO.checkId( - checkUserWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, + fromAccountId: AccountId?, + toAccountId: AccountId?, fromCurrencyId: CryptoCurrency.ID, toCurrencyId: CryptoCurrency.ID, ): Boolean { - return userWalletId == checkUserWalletId.stringValue && - toCryptoCurrencyId == toCurrencyId.value && - fromCryptoCurrencyId == fromCurrencyId.value + return this.fromUserWalletId == fromUserWalletId.stringValue && + this.toUserWalletId == toUserWalletId.stringValue && + this.fromTokensResponse?.accountId == fromAccountId?.value && + this.toTokensResponse?.accountId == toAccountId?.value && + fromCryptoCurrencyId == fromCurrencyId.value && + toCryptoCurrencyId == toCurrencyId.value } @Suppress("LongParameterList") private fun List.updateList( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -243,7 +257,8 @@ internal class DefaultSwapTransactionRepository( ): List { return addOrReplace( item = listConverter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -252,7 +267,10 @@ internal class DefaultSwapTransactionRepository( ), predicate = { swapTxList -> swapTxList.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, + fromAccountId = fromAccount?.accountId, + toAccountId = toAccount?.accountId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt index 04e3203f28..64c6dae739 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt @@ -10,7 +10,7 @@ class TokenInfoConverter : Converter { override fun convert(value: CryptoCurrency): LeastTokenInfo { return LeastTokenInfo( contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = value.network.backendId, + network = value.network.rawId, ) } diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt index aa9d27b4b7..db7f9ebd06 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -31,7 +31,8 @@ internal class SavedSwapTransactionListConverter( } override fun convert(value: SwapTransactionListModel) = SwapTransactionListDTO( - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( @@ -79,7 +80,8 @@ internal class SavedSwapTransactionListConverter( txStatuses = txStatuses, ) }, - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, @@ -98,14 +100,16 @@ internal class SavedSwapTransactionListConverter( @Suppress("LongParameterList") fun default( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, toAccount: Account?, tokenTransactions: List, ) = SwapTransactionListDTO( - userWalletId = userWalletId.stringValue, + fromUserWalletId = fromUserWalletId.stringValue, + toUserWalletId = toUserWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( diff --git a/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt index ab4738e367..d928a75938 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt @@ -9,7 +9,9 @@ import java.math.BigDecimal @JsonClass(generateAdapter = true) internal data class SwapTransactionListDTO( @Json(name = "userWalletId") - val userWalletId: String, + val fromUserWalletId: String, + @Json(name = "toUserWalletId") + val toUserWalletId: String = fromUserWalletId, @Json(name = "fromCryptoCurrencyId") val fromCryptoCurrencyId: String, @Json(name = "toCryptoCurrencyId") diff --git a/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt new file mode 100644 index 0000000000..92343937bb --- /dev/null +++ b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt @@ -0,0 +1,709 @@ +package com.tangem.data.swap + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.request.LeastTokenInfo +import com.tangem.datasource.api.express.models.request.PairsRequestBody +import com.tangem.datasource.api.express.models.response.* +import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.express.models.* +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapStatus +import com.tangem.domain.swap.models.SwapTxType +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultSwapRepositoryV2Test { + + private val tangemExpressApi: TangemExpressApi = mockk() + private val expressRepository: ExpressRepository = mockk() + private val appPreferencesStore: AppPreferencesStore = mockk(relaxed = true) + private val dataSignatureVerifier: DataSignatureVerifier = mockk() + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk() + private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk() + private val moshi: Moshi = Moshi.Builder().build() + + private val repository = DefaultSwapRepositoryV2( + tangemExpressApi = tangemExpressApi, + expressRepository = expressRepository, + coroutineDispatcher = TestingCoroutineDispatcherProvider(), + appPreferencesStore = appPreferencesStore, + dataSignatureVerifier = dataSignatureVerifier, + singleQuoteStatusSupplier = singleQuoteStatusSupplier, + singleQuoteStatusFetcher = singleQuoteStatusFetcher, + moshi = moshi, + ) + + @BeforeEach + fun resetMocks() { + clearMocks( + tangemExpressApi, + expressRepository, + appPreferencesStore, + dataSignatureVerifier, + singleQuoteStatusSupplier, + singleQuoteStatusFetcher, + ) + } + + // region getPairs(SwapCurrencyStatus, SwapCurrencyStatus) + + @Test + fun `getPairs with SwapCurrencyStatus returns mapped pairs when providers match`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).hasSize(2) + assertThat(result.first().from).isEqualTo(primaryStatus) + assertThat(result.first().to).isEqualTo(secondaryStatus) + assertThat(result.first().providers).hasSize(1) + assertThat(result.first().providers.first().providerId).isEqualTo(PROVIDER_ID) + } + + @Test + fun `getPairs with SwapCurrencyStatus returns empty when no providers match`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = "unknown-provider", rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `getPairs with SwapCurrencyStatus returns empty when API returns empty pairs`() = runTest { + // Arrange + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = createCryptoCurrencyStatus(primaryCoin), + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = createCryptoCurrencyStatus(secondaryCoin), + account = mockk(), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(emptyList()) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).isEmpty() + } + + // endregion + + // region getPairs(UserWallet, CryptoCurrency, List) + + @Test + fun `getPairs with currency status list returns mapped pairs`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + userWallet = userWallet, + initialCurrency = primaryCoin, + cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus), + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).hasSize(2) + assertThat(result.first().from).isEqualTo(primaryStatus) + assertThat(result.first().to).isEqualTo(secondaryStatus) + } + + @Test + fun `getPairs with SendWithSwap only fetches forward pairs`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + userWallet = userWallet, + initialCurrency = primaryCoin, + cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus), + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.SendWithSwap, + ) + + // Assert + assertThat(result).hasSize(1) + + // SendWithSwap should call getPairs only once (forward), not twice (forward + reverse) + coVerify(exactly = 1) { tangemExpressApi.getPairs(any(), any(), any()) } + } + + // endregion + + // region getSwapQuote + + @Test + fun `getSwapQuote returns correct quote model for fromAmount`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = "0xAllowance", + minAmount = BigDecimal.ONE, + quoteId = "quote-123", + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + val result = repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal.ONE, + amountType = SwapAmountType.From, + provider = expressProvider, + rateType = ExpressRateType.Float, + ) + + // Assert + assertThat(result.provider).isEqualTo(expressProvider) + assertThat(result.toTokenAmount).isEqualTo(BigDecimal("1.00000000")) + assertThat(result.fromTokenAmount).isEqualTo(BigDecimal("1.000000000000000000")) + assertThat(result.allowanceContract).isEqualTo("0xAllowance") + assertThat(result.quoteId).isEqualTo("quote-123") + } + + @Test + fun `getSwapQuote sends fromAmount when amountType is From`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = null, + minAmount = BigDecimal.ONE, + quoteId = null, + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal("2.5"), + amountType = SwapAmountType.From, + provider = expressProvider, + rateType = ExpressRateType.Float, + ) + + // Assert — fromAmount is set, toAmount is null + coVerify { + tangemExpressApi.getExchangeQuote( + fromAmount = "2500000000000000000", + toAmount = null, + fromNetwork = ETH_BACKEND_ID, + fromContractAddress = "0", + fromDecimals = 18, + toNetwork = BTC_BACKEND_ID, + toContractAddress = "0", + toDecimals = 8, + providerId = PROVIDER_ID, + rateType = "float", + userWalletId = any(), + refCode = any(), + ) + } + } + + @Test + fun `getSwapQuote sends toAmount when amountType is To`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = null, + minAmount = BigDecimal.ONE, + quoteId = null, + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal("1.5"), + amountType = SwapAmountType.To, + provider = expressProvider, + rateType = ExpressRateType.Fixed, + ) + + // Assert — toAmount is set, fromAmount is null + coVerify { + tangemExpressApi.getExchangeQuote( + fromAmount = null, + toAmount = "150000000", + fromNetwork = ETH_BACKEND_ID, + fromContractAddress = "0", + fromDecimals = 18, + toNetwork = BTC_BACKEND_ID, + toContractAddress = "0", + toDecimals = 8, + providerId = PROVIDER_ID, + rateType = "fixed", + userWalletId = any(), + refCode = any(), + ) + } + } + + // endregion + + // region getExchangeStatus + + @Test + fun `getExchangeStatus returns converted status model`() = runTest { + // Arrange + val statusResponse = ExchangeStatusResponse( + providerId = PROVIDER_ID, + status = ExchangeStatus.Finished, + externalTxId = "ext-tx-1", + externalTxUrl = "https://example.com/tx/1", + error = null, + ) + + coEvery { + tangemExpressApi.getExchangeStatus(any(), any(), any()) + } returns ApiResponse.Success(statusResponse) + + // Act + val result = repository.getExchangeStatus(userWallet = userWallet, txId = "tx-123") + + // Assert + assertThat(result.providerId).isEqualTo(PROVIDER_ID) + assertThat(result.status).isEqualTo(SwapStatus.Finished) + assertThat(result.txId).isEqualTo("ext-tx-1") + assertThat(result.txExternalUrl).isEqualTo("https://example.com/tx/1") + } + + // endregion + + // region swapTransactionSent + + @Test + fun `swapTransactionSent calls exchangeSent API`() = runTest { + // Arrange + val fromStatus = createCryptoCurrencyStatus(primaryCoin) + + coEvery { + tangemExpressApi.exchangeSent(any(), any(), any()) + } returns ApiResponse.Success(ExchangeSentResponseBody(txId = "tx-1", status = "ok")) + + // Act + repository.swapTransactionSent( + userWallet = userWallet, + fromCryptoCurrencyStatus = fromStatus, + payInAddress = "0xPayIn", + txId = "tx-1", + txHash = "0xHash", + txExtraId = null, + ) + + // Assert + coVerify { + tangemExpressApi.exchangeSent( + userWalletId = any(), + refCode = any(), + body = match { body -> + body.txId == "tx-1" && + body.txHash == "0xHash" && + body.payinAddress == "0xPayIn" && + body.payinExtraId == null + }, + ) + } + } + + // endregion + + // region filterYieldSupplyProvider + + @Test + fun `getPairs filters out DEX providers when yield supply is active`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf( + SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + ), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(dexProvider, cexProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert — only CEX provider should remain + assertThat(result).hasSize(2) + val providers = result.first().providers + assertThat(providers).hasSize(1) + assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX) + } + + // endregion + + // region getSwapData + + @Test + fun `getSwapData throws InvalidSignatureError when signature verification fails`() = runTest { + // Arrange + val fromStatus = createCryptoCurrencyStatus(primaryCoin) + val exchangeDataResponse = ExchangeDataResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + txId = "tx-123", + txDetailsJson = "{}", + signature = "invalid-sig", + ) + + coEvery { + tangemExpressApi.getExchangeData( + fromContractAddress = any(), + toContractAddress = any(), + fromNetwork = any(), + toNetwork = any(), + fromAddress = any(), + toAddress = any(), + fromDecimals = any(), + toDecimals = any(), + fromAmount = any(), + toAmount = any(), + providerId = any(), + rateType = any(), + requestId = any(), + refundAddress = any(), + refundExtraId = any(), + userWalletId = any(), + partnerOperationType = any(), + refCode = any(), + toExtraId = any(), + quoteId = any(), + ) + } returns ApiResponse.Success(exchangeDataResponse) + + every { dataSignatureVerifier.verifySignature(any(), any()) } returns false + + // Act & Assert + assertThrows { + repository.getSwapData( + userWallet = userWallet, + fromCryptoCurrencyStatus = fromStatus, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal.ONE, + amountType = SwapAmountType.From, + toAddress = "0xToAddress", + toExtraId = null, + expressProvider = cexProvider, + rateType = ExpressRateType.Float, + expressOperationType = ExpressOperationType.SWAP, + quoteId = null, + ) + } + } + + // endregion + + private companion object { + const val ETH_BACKEND_ID = "ethereum" + const val BTC_BACKEND_ID = "bitcoin" + const val PROVIDER_ID = "dex-provider-1" + const val CEX_PROVIDER_ID = "cex-provider-1" + + val userWallet: UserWallet = MockUserWalletFactory.create() + + val ethNetwork: Network = mockk(relaxed = true) { + every { rawId } returns ETH_BACKEND_ID + } + + val btcNetwork: Network = mockk(relaxed = true) { + every { rawId } returns BTC_BACKEND_ID + } + + val primaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { network } returns ethNetwork + every { decimals } returns 18 + every { id } returns mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("ethereum") + } + } + + val secondaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { network } returns btcNetwork + every { decimals } returns 8 + every { id } returns mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin") + } + } + + val expressProvider = ExpressProvider( + providerId = PROVIDER_ID, + rateTypes = listOf(ExpressRateType.Float), + name = "Test DEX", + type = ExpressProviderType.DEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + val dexProvider = expressProvider + + val cexProvider = ExpressProvider( + providerId = CEX_PROVIDER_ID, + rateTypes = listOf(ExpressRateType.Float), + name = "Test CEX", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + fun createCryptoCurrencyStatus(currency: CryptoCurrency): CryptoCurrencyStatus { + val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { yieldSupplyStatus } returns null + every { networkAddress } returns mockk(relaxed = true) { + every { defaultAddress } returns mockk(relaxed = true) { + every { value } returns "0xAddress" + } + } + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + fun createCryptoCurrencyStatusWithActiveYield(currency: CryptoCurrency): CryptoCurrencyStatus { + val yieldStatus: YieldSupplyStatus = mockk { + every { isActive } returns true + } + val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { yieldSupplyStatus } returns yieldStatus + every { networkAddress } returns mockk(relaxed = true) { + every { defaultAddress } returns mockk(relaxed = true) { + every { value } returns "0xAddress" + } + } + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + } +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 077f772d62..b04f24e429 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -134,7 +134,7 @@ internal class DefaultCurrenciesRepository( } override fun isNetworkFeeZero(userWalletId: UserWalletId, network: Network): Boolean { - val blockchain = Blockchain.fromNetworkId(network.backendId) + val blockchain = Blockchain.fromNetworkId(network.rawId) return blockchain?.isNetworkFeeZero() == true } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt index 9f1b39e4d4..f258d37a0b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt @@ -2,7 +2,11 @@ package com.tangem.data.tokens.repository import com.tangem.blockchain.blockchains.ethereum.eip1559.isGaslessTxSupported import com.tangem.blockchain.blockchains.polkadot.ExistentialDepositProvider -import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.FeeResourceAmountProvider +import com.tangem.blockchain.common.MinimumSendAmountProvider +import com.tangem.blockchain.common.ReserveAmountProvider +import com.tangem.blockchain.common.UtxoAmountLimitProvider +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.getTotalStakingBalance import com.tangem.data.tokens.converters.UtxoConverter import com.tangem.domain.models.currency.CryptoCurrency @@ -67,7 +71,7 @@ internal class DefaultCurrencyChecksRepository( } override fun isNetworkSupportedForGaslessTx(network: Network): Boolean { - val blockchain = Blockchain.fromId(network.rawId) + val blockchain = network.toBlockchain() return blockchain.isGaslessTxSupported } diff --git a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/di/TokenSyncDataModule.kt b/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/di/TokenSyncDataModule.kt deleted file mode 100644 index 525a7612fa..0000000000 --- a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/di/TokenSyncDataModule.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.data.tokensync.di - -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory -import com.tangem.data.common.network.NetworkFactory -import com.tangem.data.tokensync.repository.DefaultTokenSyncRepository -import com.tangem.data.tokensync.store.TokenSyncStoreFactory -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object TokenSyncDataModule { - - @Provides - @Singleton - fun provideTokenSyncRepository( - walletManagersFacade: WalletManagersFacade, - tangemTechApi: TangemTechApi, - userWalletsListRepository: UserWalletsListRepository, - networkFactory: NetworkFactory, - appPreferencesStore: AppPreferencesStore, - tokenSyncStoreFactory: TokenSyncStoreFactory, - responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, - dispatchers: CoroutineDispatcherProvider, - excludedBlockchains: ExcludedBlockchains, - ): TokenSyncRepository { - return DefaultTokenSyncRepository( - walletManagersFacade = walletManagersFacade, - tangemTechApi = tangemTechApi, - userWalletsListRepository = userWalletsListRepository, - networkFactory = networkFactory, - appPreferencesStore = appPreferencesStore, - tokenSyncStoreFactory = tokenSyncStoreFactory, - responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, - dispatchers = dispatchers, - excludedBlockchains = excludedBlockchains, - ) - } -} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt index 29395fce20..bdff8734da 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt @@ -38,7 +38,7 @@ internal class DefaultAllowanceRepository( allowance >= requiredAmount -> AllowanceInfo.Enough(allowance) allowance > BigDecimal.ZERO && allowance < requiredAmount && BlockchainUtils.isTetherInEthereum( - blockchainId = cryptoCurrency.network.rawId, + networkId = cryptoCurrency.network.rawId, contractAddress = cryptoCurrency.contractAddress, ) -> AllowanceInfo.ResetNeeded(allowance, requiredAmount) else -> AllowanceInfo.NotEnough(allowance, requiredAmount) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 849883a543..2664312cb5 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -1,7 +1,7 @@ package com.tangem.data.transaction -import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder @@ -45,7 +45,7 @@ class DefaultGaslessTransactionRepository( val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() if (supportedTokensData.isSuccess) { - val networkBlockchain = Blockchain.fromId(network.rawId) + val networkBlockchain = network.toBlockchain() val supportedTokens = supportedTokensData.result.tokens .filter { it.chainId == networkBlockchain.getChainId() @@ -100,7 +100,7 @@ class DefaultGaslessTransactionRepository( network: Network, eip7702Auth: Eip7702Authorization?, ): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) { - val blockchain = Blockchain.fromId(network.rawId) + val blockchain = network.toBlockchain() val transactionRequest = gaslessTransactionRequestBuilder.build( gaslessTransaction = gaslessTransactionData, signature = signature, @@ -124,7 +124,7 @@ class DefaultGaslessTransactionRepository( } override fun getChainIdForNetwork(network: Network): Int { - val networkBlockchain = Blockchain.fromId(network.rawId) + val networkBlockchain = network.toBlockchain() return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 06da7b3fa9..ae96776efa 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -61,7 +61,7 @@ internal class DefaultTransactionRepository( derivationPath = network.derivationPath.value, ) ?: error("Wallet manager not found") - val extras = txExtras ?: getMemoExtras(networkId = network.rawId, memo) + val extras = txExtras ?: getMemoExtras(networkId = network.id.rawId, memo) val patchedDestination = if (amount.type is AmountType.TokenYieldSupply) { walletManager.getYieldModuleAddress() @@ -128,7 +128,7 @@ internal class DefaultTransactionRepository( destination = destination, userWalletId = userWalletId, network = network, - txExtras = getMemoExtras(networkId = network.rawId, memo = memo) ?: extras, + txExtras = getMemoExtras(networkId = network.id.rawId, memo = memo) ?: extras, ) } @@ -262,7 +262,7 @@ internal class DefaultTransactionRepository( fee = fee ?: Fee.Common(amount = amount), destination = destination, ).copy( - extras = getMemoExtras(networkId = network.rawId, memo = memo), + extras = getMemoExtras(networkId = network.id.rawId, memo = memo), ) validator.validate(transactionData = transactionData) @@ -312,7 +312,7 @@ internal class DefaultTransactionRepository( nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras { - val blockchain = Blockchain.fromNetworkId(networkId = network.backendId) + val blockchain = Blockchain.fromNetworkId(networkId = network.rawId) ?: error("Blockchain not found") return when { blockchain.isEvm() -> { @@ -332,8 +332,8 @@ internal class DefaultTransactionRepository( } @Suppress("CyclomaticComplexMethod") - private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { - val blockchain = Blockchain.fromId(networkId) + private fun getMemoExtras(networkId: Network.RawID, memo: String?): TransactionExtras? { + val blockchain = networkId.toBlockchain() if (memo == null) return null return when (blockchain) { Blockchain.Stellar -> { diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index be0a8b25f4..2d6b86ba9a 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -30,7 +31,7 @@ class MockedGaslessTransactionRepository( } override fun getChainIdForNetwork(network: Network): Int { - val networkBlockchain = Blockchain.fromId(network.rawId) + val networkBlockchain = network.toBlockchain() return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") } diff --git a/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt b/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt index 0211c42b33..b7f49cec2b 100644 --- a/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt +++ b/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt @@ -171,7 +171,11 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns NotEnough when partial allowance for non-tether token`() = runTest { - val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "usd-coin") + val token = buildToken( + rawNetworkId = "ethereum", + rawCurrencyId = "usd-coin", + contractAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + ) coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -187,7 +191,7 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns NotEnough when partial allowance for tether on non-ethereum network`() = runTest { - val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "tether") + val token = buildToken(rawNetworkId = "polygon-pos", rawCurrencyId = "tether") coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -200,7 +204,7 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns ResetNeeded when partial allowance for tether on ethereum`() = runTest { - val token = buildToken(rawNetworkId = "ETH", rawCurrencyId = "tether") + val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "tether") coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -216,7 +220,7 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns ResetNeeded when partial allowance for tether on ethereum testnet`() = runTest { - val token = buildToken(rawNetworkId = "ETH/test", rawCurrencyId = "tether") + val token = buildToken(rawNetworkId = "ethereum/test", rawCurrencyId = "tether") coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -248,8 +252,7 @@ class DefaultAllowanceRepositoryTest { private fun buildNetwork(rawNetworkId: String): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID(rawNetworkId), derivationPath), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), name = rawNetworkId.replaceFirstChar { it.uppercase() }, currencySymbol = "ETH", derivationPath = derivationPath, @@ -263,7 +266,7 @@ class DefaultAllowanceRepositoryTest { } private fun buildToken( - rawNetworkId: String = "ETH", + rawNetworkId: String = "ethereum", rawCurrencyId: String = "tether", contractAddress: String = "0xdAC17F958D2ee523a2206206994597C13D831ec7", ): CryptoCurrency.Token { diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 88d718323d..386c70565f 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -10,6 +10,16 @@ plugins { android { namespace = "com.tangem.data.visa" + + // `src/prodDi/` holds production DI bindings for TangemPay repos with a `mocked` counterpart. + // Wired into every build type EXCEPT `mocked`, which supplies its own bindings from `src/mocked/`. + buildTypes.configureEach { + if (name != "mocked") { + sourceSets.named(name) { + java.srcDir("src/prodDi/kotlin") + } + } + } } tasks.withType().configureEach { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt index 48607769ea..ca7b011917 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt @@ -7,8 +7,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.error.UniversalError import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.data.pay.util.TangemPayErrorConverter -import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory @@ -16,14 +16,8 @@ import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val TAG = "TangemPay: DefaultTangemPayCryptoCurrencyFactory" -/** - * Custom token parameters. Will be used only for F&F. - */ -private const val TOKEN_ID = "usd-coin" -private const val TOKEN_NAME = "USDC" -private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" -private const val TOKEN_DECIMALS = 6 +@Deprecated("Use TangemPayCurrencyFactory instead") internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor( excludedBlockchains: ExcludedBlockchains, private val errorConverter: TangemPayErrorConverter, @@ -47,32 +41,11 @@ internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor( ) cryptoCurrencyFactory.createToken( network = requireNotNull(network), - rawId = CryptoCurrency.RawID(TOKEN_ID), - name = TOKEN_NAME, - symbol = TOKEN_NAME, - contractAddress = TOKEN_CONTRACT_ADDRESS, - decimals = TOKEN_DECIMALS, - ) - }.mapLeft { exception -> - TangemLogger.withTag(TAG).e("Error", exception) - errorConverter.convert(exception) - } - } - - override fun create(userWallet: UserWallet): Either { - return catch { - val network = networkFactory.create( - blockchain = VisaUtilities.visaBlockchain, - extraDerivationPath = null, - userWallet = userWallet, - ) - cryptoCurrencyFactory.createToken( - network = requireNotNull(network), - rawId = CryptoCurrency.RawID(TOKEN_ID), - name = TOKEN_NAME, - symbol = TOKEN_NAME, - contractAddress = TOKEN_CONTRACT_ADDRESS, - decimals = TOKEN_DECIMALS, + rawId = CryptoCurrency.RawID(TangemPayCurrencyFactory.TOKEN_ID), + name = TangemPayCurrencyFactory.TOKEN_NAME, + symbol = TangemPayCurrencyFactory.TOKEN_NAME, + contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS, + decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, ) }.mapLeft { exception -> TangemLogger.withTag(TAG).e("Error", exception) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 8f71e7360f..8ca4cbc613 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -2,7 +2,7 @@ package com.tangem.data.pay import com.tangem.common.card.FirmwareVersion import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.TangemPayEligibilityType +import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 92c24a5b12..23c8ce5ea3 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -1,11 +1,18 @@ package com.tangem.data.pay.converter -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convert -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convertBack +import arrow.core.getOrElse +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue -import com.tangem.utils.converter.TwoWayConverter +import com.tangem.domain.models.pay.TangemPayCard +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.wallet.UserWalletId +import javax.inject.Inject +import javax.inject.Singleton /** * Two-way converter between [PaymentAccountStatusValue] and [PaymentAccountStatusValueDM]. @@ -15,10 +22,12 @@ import com.tangem.utils.converter.TwoWayConverter * * [convertBack] maps data model → domain. All restored statuses have [StatusSource.CACHE] as source. */ -internal object PaymentAccountStatusValueDMConverter : - TwoWayConverter { +@Singleton +internal class PaymentAccountStatusValueDMConverter @Inject constructor( + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, +) { - override fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? { + fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? { return when (value) { is PaymentAccountStatusValue.NotCreated -> PaymentAccountStatusValueDM.NotCreated() is PaymentAccountStatusValue.UnderReview -> PaymentAccountStatusValueDM.UnderReview( @@ -26,27 +35,23 @@ internal object PaymentAccountStatusValueDMConverter : customerId = value.customerId, ) is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard() - is PaymentAccountStatusValue.Locked -> PaymentAccountStatusValueDM.ActiveCard( - isLocked = true, + is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount( customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, currencyCode = value.currencyCode, depositAddress = value.depositAddress, - isPinSet = value.isPinSet, - fiatBalance = value.fiatBalance.toDM(), - cryptoBalance = value.cryptoBalance.toDM(), - ) - is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveCard( - isLocked = false, - customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, - currencyCode = value.currencyCode, - depositAddress = value.depositAddress, - isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), + cards = value.cards.map { card -> + PaymentAccountStatusValueDM.TangemPayCard( + id = card.id, + hasPinCode = card.hasPinCode, + displayName = card.displayName?.value, + actualDailyLimit = card.limit?.actualCardLimit?.amount, + adminDailyLimit = card.limit?.adminCardLimit?.amount, + isFrozen = card.isFrozen, + lastDigits = card.lastDigits, + ) + }, ) is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed( customerId = value.customerId, @@ -64,7 +69,7 @@ internal object PaymentAccountStatusValueDMConverter : } } - override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { + fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { return when (value) { is PaymentAccountStatusValueDM.Empty -> PaymentAccountStatusValue.Empty is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated @@ -74,31 +79,32 @@ internal object PaymentAccountStatusValueDMConverter : is PaymentAccountStatusValueDM.IssuingCard -> PaymentAccountStatusValue.IssuingCard( source = StatusSource.CACHE, ) - is PaymentAccountStatusValueDM.ActiveCard -> if (value.isLocked) { - PaymentAccountStatusValue.Locked( - source = StatusSource.CACHE, - customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, - currencyCode = value.currencyCode, - depositAddress = value.depositAddress, - isPinSet = value.isPinSet, - fiatBalance = value.fiatBalance.toDomain(), - cryptoBalance = value.cryptoBalance.toDomain(), - ) - } else { - PaymentAccountStatusValue.Loaded( - source = StatusSource.CACHE, - customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, - currencyCode = value.currencyCode, - depositAddress = value.depositAddress, - isPinSet = value.isPinSet, - fiatBalance = value.fiatBalance.toDomain(), - cryptoBalance = value.cryptoBalance.toDomain(), - ) - } + is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded( + source = StatusSource.CACHE, + customerId = value.customerId, + currencyCode = value.currencyCode, + depositAddress = value.depositAddress, + fiatBalance = value.fiatBalance.toDomain(), + cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + cards = value.cards.map { card -> + TangemPayCard( + id = card.id, + hasPinCode = card.hasPinCode, + displayName = card.displayName?.let { CardDisplayName(it).getOrElse { null } }, + limit = TangemPayCardLimitData( + actualCardLimit = card.actualDailyLimit?.let { limit -> + TangemPayCardLimit(limit, TangemPayCardLimitPeriod.DAY) + }, + adminCardLimit = card.adminDailyLimit?.let { limit -> + TangemPayCardLimit(limit, TangemPayCardLimitPeriod.DAY) + }, + ), + isFrozen = card.isFrozen, + lastDigits = card.lastDigits, + ) + }, + ) is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( source = StatusSource.CACHE, kycStatus = value.kycStatus, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 3887f44e6f..7f41dabc9c 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -2,10 +2,12 @@ package com.tangem.data.pay.di import android.content.Context import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory import com.tangem.data.pay.DefaultTangemPayEligibilityManager +import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer import com.tangem.data.pay.repository.* @@ -24,13 +26,13 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.* +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase +import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository -import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Binds @@ -49,18 +51,10 @@ internal interface TangemPayDataModule { @Singleton fun bindKycRepository(repository: DefaultKycRepository): KycRepository - @Binds - @Singleton - fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository - @Binds @Singleton fun bindTangemPayTxHistoryRepository(repository: DefaultTangemPayTxHistoryRepository): TangemPayTxHistoryRepository - @Binds - @Singleton - fun bindCardDetailsRepository(repository: DefaultTangemPayCardDetailsRepository): TangemPayCardDetailsRepository - @Binds @Singleton fun bindTangemPaySwapRepository(repository: DefaultTangemPayWithdrawRepository): TangemPayWithdrawRepository @@ -69,6 +63,10 @@ internal interface TangemPayDataModule { @Singleton fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository + @Binds + @Singleton + fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository + @Binds @Singleton fun bindTangemPayCryptoCurrencyFactory( @@ -112,6 +110,7 @@ internal interface TangemPayDataModule { @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, scope: AppCoroutineScope, + converter: PaymentAccountStatusValueDMConverter, ): PaymentAccountStatusesStore { return PaymentAccountStatusesStore( runtimeStore = RuntimeSharedStore(), @@ -121,9 +120,11 @@ internal interface TangemPayDataModule { types = mapWithStringKeyTypes(), defaultValue = emptyMap(), ), + corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() }, produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") }, scope = scope, ), + converter = converter, scope = scope, ) } @@ -139,21 +140,20 @@ internal interface TangemPayDataModule { ) {} } + @Provides + fun provideSetTangemPayCardLimitUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): SetTangemPayCardLimitUseCase { + return SetTangemPayCardLimitUseCase(cardDetailsRepository, paymentAccountStatusFetcher) + } + @Provides @Singleton - fun provideTangemPayMainScreenCustomerInfoUseCase( - repository: OnboardingRepository, - customerOrderRepository: CustomerOrderRepository, - tangemPayOnboardingRepository: OnboardingRepository, - eligibilityManager: TangemPayEligibilityManager, - deviceSecurity: DeviceSecurityInfoProvider, - ): TangemPayMainScreenCustomerInfoUseCase { - return TangemPayMainScreenCustomerInfoUseCase( - onboardingRepository = repository, - customerOrderRepository = customerOrderRepository, - eligibilityManager = eligibilityManager, - deviceSecurity = deviceSecurity, - ) + fun provideGetTangemPayCryptoCurrencyStatusUseCase( + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + ): GetPaymentAccountCryptoCurrencyStatusUseCase { + return GetPaymentAccountCryptoCurrencyStatusUseCase(paymentAccountStatusSupplier) } @Provides diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt new file mode 100644 index 0000000000..ede6bba797 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt @@ -0,0 +1,49 @@ +package com.tangem.data.pay.entity + +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.network.NetworkFactory +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.requireUserWalletsSync +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class TangemPayCurrencyFactory @Inject constructor( + excludedBlockchains: ExcludedBlockchains, + private val userWalletsListRepository: UserWalletsListRepository, + private val networkFactory: NetworkFactory, +) { + private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + CryptoCurrencyFactory(excludedBlockchains) + } + + fun create(userWalletId: UserWalletId): CryptoCurrency.Token { + val userWallet = userWalletsListRepository.requireUserWalletsSync() + .firstOrNull { it.walletId == userWalletId } + ?: error("User wallet with id $userWalletId not found") + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + userWallet = userWallet, + extraDerivationPath = null, + ) + return cryptoCurrencyFactory.createToken( + network = requireNotNull(network), + rawId = CryptoCurrency.RawID(TOKEN_ID), + name = TOKEN_NAME, + symbol = TOKEN_NAME, + contractAddress = TOKEN_CONTRACT_ADDRESS, + decimals = TOKEN_DECIMALS, + ) + } + + companion object { + internal const val TOKEN_ID = "usd-coin" + internal const val TOKEN_NAME = "USDC" + internal const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" + internal const val TOKEN_DECIMALS = 6 + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index c55ac9cb0c..2f13c10d8d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.data.pay.flow import arrow.core.Either +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.StatusSource @@ -8,6 +9,8 @@ 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.kyc.KycStatus +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardLimitData import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher @@ -38,6 +41,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val customerOrderRepository: CustomerOrderRepository, private val deviceSecurity: DeviceSecurityInfoProvider, private val dispatchers: CoroutineDispatcherProvider, + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, private val eligibilityManager: TangemPayEligibilityManager, ) : PaymentAccountStatusFetcher { @@ -132,7 +136,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( }, ifRight = { customerInfo -> logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}") - val status = customerInfo.mapToPaymentAccountStatus() + val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId) if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) { // If order id wasn't saved -> start order creation and get customer info onboardingRepository.createOrder(account.userWalletId) @@ -159,7 +163,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( KycStatus.PENDING, KycStatus.INIT, KycStatus.REJECTED, - -> return customerInfo.mapToPaymentAccountStatus() + -> return customerInfo.mapToPaymentAccountStatus(account.userWalletId) KycStatus.APPROVED -> Unit // proceed to order check } @@ -174,7 +178,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( when (orderData.status) { OrderStatus.CANCELED -> handleCanceledOrder(account, orderData) OrderStatus.COMPLETED -> handleCompletedOrder(account) - OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable OrderStatus.NEW, OrderStatus.PROCESSING, -> { @@ -214,7 +217,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( OrderStatus.COMPLETED -> return handleCompletedOrder(account) OrderStatus.NEW, OrderStatus.PROCESSING, - OrderStatus.UNKNOWN, -> Unit // Continue polling } }, @@ -237,11 +239,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId) .fold( ifLeft = { it.mapToPaymentAccountStatus(account.userWalletId) }, - ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() }, + ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus(account.userWalletId) }, ) } - private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue { + private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { val cardInfo = this.cardInfo val productInstance = this.productInstance @@ -264,6 +266,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState( + userWalletId = userWalletId, productInstance = productInstance, cardInfo = cardInfo, customerId = requireNotNull(customerId) { "CustomerId must not be null" }, @@ -273,34 +276,34 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } private fun convertToContentState( + userWalletId: UserWalletId, productInstance: CustomerInfo.ProductInstance, cardInfo: CustomerInfo.CardInfo, customerId: String, ): PaymentAccountStatusValue { - return when (productInstance.frozenState) { - TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked( - source = StatusSource.ACTUAL, - customerId = customerId, - cardId = productInstance.cardId, - lastFourDigits = cardInfo.lastFourDigits, - currencyCode = cardInfo.currencyCode, - depositAddress = cardInfo.depositAddress, - isPinSet = cardInfo.isPinSet, - fiatBalance = cardInfo.fiatBalance, - cryptoBalance = cardInfo.cryptoBalance, - ) - else -> PaymentAccountStatusValue.Loaded( - source = StatusSource.ACTUAL, - customerId = customerId, - cardId = productInstance.cardId, - lastFourDigits = cardInfo.lastFourDigits, - currencyCode = cardInfo.currencyCode, - depositAddress = cardInfo.depositAddress, - isPinSet = cardInfo.isPinSet, - fiatBalance = cardInfo.fiatBalance, - cryptoBalance = cardInfo.cryptoBalance, - ) - } + val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) + return PaymentAccountStatusValue.Loaded( + source = StatusSource.ACTUAL, + customerId = customerId, + currencyCode = cardInfo.currencyCode, + depositAddress = cardInfo.depositAddress, + fiatBalance = cardInfo.fiatBalance, + cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, + cards = listOf( + TangemPayCard( + id = productInstance.cardId, + hasPinCode = cardInfo.isPinSet, + displayName = productInstance.displayName, + limit = TangemPayCardLimitData( + actualCardLimit = productInstance.actualCardLimit, + adminCardLimit = productInstance.adminCardLimit, + ), + isFrozen = productInstance.frozenState is TangemPayCardFrozenState.Frozen, + lastDigits = cardInfo.lastFourDigits, + ), + ), + ) } private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt index 8cc392d6ed..8871a7d804 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt @@ -1,8 +1,8 @@ package com.tangem.data.pay.repository import arrow.core.Either +import com.tangem.data.pay.util.OrderStatusConverter import com.tangem.datasource.api.pay.TangemPayApi -import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.OrderData import com.tangem.domain.pay.model.OrderStatus @@ -19,13 +19,7 @@ internal class DefaultCustomerOrderRepository @Inject constructor( return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId) }.map { response -> - val status = when (response.result?.status) { - null -> OrderStatus.PROCESSING - OrderResponse.Result.Status.NEW -> OrderStatus.NEW - OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING - OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED - OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED - } + val status = response.result?.status?.let(OrderStatusConverter::convert) ?: OrderStatus.PROCESSING OrderData( customerId = response.result?.customerId.orEmpty(), status = status, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 8eff052a75..a05f3e7b14 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.flatMap +import arrow.core.getOrElse import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.pay.store.PaymentAccountStatusesStore @@ -15,11 +16,14 @@ import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.TangemPayEligibilityType import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.pay.TangemPayCardLimit +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.datasource.TangemPayAuthDataSource @@ -197,11 +201,16 @@ internal class DefaultOnboardingRepository @Inject constructor( } cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState) + val displayName = instance.displayName?.ifEmpty { null } + ProductInstance( id = instance.id, cardId = instance.cardId, frozenState = cardFrozenState, status = instance.status.toDomain(), + displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null, + actualCardLimit = instance.actualCardLimit?.parseCardLimit(), + adminCardLimit = instance.adminCardLimit?.parseCardLimit(), ) } return CustomerInfo( @@ -216,6 +225,13 @@ internal class DefaultOnboardingRepository @Inject constructor( } } + private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit { + return TangemPayCardLimit( + amount = amount, + period = TangemPayCardLimitPeriod.fromString(periodType), + ) + } + private fun sendKycAnalytics(kycStatus: KycStatus) { val event = when (kycStatus) { KycStatus.APPROVED -> TangemPayAnalyticsEvents.KycPassedAndOrderCreated() diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt new file mode 100644 index 0000000000..0e85fa2f63 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -0,0 +1,107 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import com.tangem.core.error.UniversalError +import com.tangem.data.pay.util.OrderStatusConverter +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.ReissueCardRequest +import com.tangem.datasource.api.pay.models.response.OrderResponse +import com.tangem.datasource.local.visa.TangemPayReissueCardStore +import com.tangem.domain.models.pay.TangemPayReissueCardFee +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.runSuspendCatching +import javax.inject.Inject + +internal class DefaultReissueCardRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, + private val tangemPayReissueCardStore: TangemPayReissueCardStore, +) : TangemPayReissueCardRepository { + + override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either = + either { + runSuspendCatching { + tangemPayReissueCardStore.getReissueFee(userWalletId)?.let { return Either.Right(it) } + } + + val response = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getFee( + authHeader = authHeader, + type = CARD_REPLACEMENT_FEE_TYPE, + ) + }.bind() + + val result = response.result + val fee = TangemPayReissueCardFee( + amount = result.amount.toBigDecimal(), + currencyCode = result.currency, + ) + + runSuspendCatching { + tangemPayReissueCardStore.storeReissueFee(userWalletId, fee) + } + + fee + } + + override suspend fun reissueCard( + userWalletId: UserWalletId, + cardId: String, + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.reissueCard( + authHeader = authHeader, + body = ReissueCardRequest(cardId = cardId), + ) + }.bind() + TangemPayReissueOrderInfo( + orderId = response.result.orderId, + orderStatus = OrderStatusConverter.convert(response.result.status), + ) + } + + override suspend fun storeReissueOrderId(cardId: String, orderId: String): Either = + runSuspendCatching { + tangemPayReissueCardStore.storeReissueOrderId(cardId, orderId) + }.fold( + onSuccess = { Unit.right() }, + onFailure = { Either.Left(VisaApiError.Unspecified) }, + ) + + override suspend fun getReissueOrderInfo( + userWalletId: UserWalletId, + cardId: String, + ): Either = either { + val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull() + + if (orderId == null) { + return null.right() + } + + val order = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getOrder(authHeader, orderId) + }.bind() + + val result = order.result ?: raise(VisaApiError.Unspecified) + + TangemPayReissueOrderInfo( + orderId = result.id, + orderStatus = when (result.status) { + OrderResponse.Result.Status.NEW -> OrderStatus.NEW + OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING + OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED + OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED + }, + ) + } + + private companion object { + const val CARD_REPLACEMENT_FEE_TYPE = "CARD_REPLACEMENT" + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index 88f54d413e..97657f4c0d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -14,11 +14,13 @@ import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.CardDetailsRequest import com.tangem.datasource.api.pay.models.request.FreezeUnfreezeCardRequest +import com.tangem.datasource.api.pay.models.request.UpdateCardRequest import com.tangem.datasource.api.pay.models.request.SetPinRequest import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance @@ -39,7 +41,7 @@ import kotlin.time.Duration.Companion.seconds private const val TAG = "TangemPay: CardDetailsRepository" -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class DefaultTangemPayCardDetailsRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, @@ -318,6 +320,54 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( } } + override suspend fun updateCardDisplayName( + cardId: String, + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either { + return catch( + block = { + requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.updateCard( + authHeader = authHeader, + body = UpdateCardRequest( + displayName = displayName.value, + ), + cardId = cardId, + ) + }.fold( + ifLeft = { error -> error.left() }, + ifRight = { Unit.right() }, + ) + }, + catch = ::catchException, + ) + } + + override suspend fun updateCardLimit( + cardId: String, + userWalletId: UserWalletId, + limit: String, + ): Either { + return catch( + block = { + requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.updateCard( + authHeader = authHeader, + body = UpdateCardRequest( + cardLimit = UpdateCardRequest.CardLimit(limit), + ), + cardId = cardId, + ) + }.fold( + ifLeft = { error -> error.left() }, + ifRight = { Unit.right() }, + ) + }, + catch = ::catchException, + ) + } + override fun cardFrozenState(cardId: String): Flow { return cardFrozenStateStore.get(cardId) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index ec8a48d880..c39190f6d8 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -10,6 +10,7 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -29,6 +30,7 @@ internal typealias WalletIdWithPaymentStatusDM = Map, private val persistenceDataStore: DataStore, + private val converter: PaymentAccountStatusValueDMConverter, scope: AppCoroutineScope, ) { @@ -39,11 +41,12 @@ internal class PaymentAccountStatusesStore( runtimeStore.store( value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) -> val account = Account.Payment(userWalletId = UserWalletId(rawUserWalletId)) - val statusValue = PaymentAccountStatusValueDMConverter.convertBack(value = statusDM) + val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM) AccountStatus.Payment(account = account, value = statusValue) }, ) } catch (e: Exception) { + runSuspendCatching { persistenceDataStore.updateData { emptyMap() } } TangemLogger.e("Error while loading cached payment account statuses", e) } } @@ -87,7 +90,7 @@ internal class PaymentAccountStatusesStore( } private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatusValue) { - val statusDM = PaymentAccountStatusValueDMConverter.convert(value = status) ?: return + val statusDM = converter.convert(value = status) ?: return persistenceDataStore.updateData { storedStatuses -> storedStatuses.toMutableMap().apply { put(key = userWalletId.stringValue, value = statusDM) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderStatusConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderStatusConverter.kt new file mode 100644 index 0000000000..2c143fd252 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderStatusConverter.kt @@ -0,0 +1,16 @@ +package com.tangem.data.pay.util + +import com.tangem.datasource.api.pay.models.response.OrderResponse +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.utils.converter.Converter + +internal object OrderStatusConverter : Converter { + override fun convert(value: OrderResponse.Result.Status): OrderStatus { + return when (value) { + OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING + OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED + OrderResponse.Result.Status.NEW -> OrderStatus.NEW + OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED + } + } +} \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/di/TangemPayDataMockedModule.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/di/TangemPayDataMockedModule.kt new file mode 100644 index 0000000000..16f3f0b9db --- /dev/null +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/di/TangemPayDataMockedModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.pay.di + +import com.tangem.data.pay.repository.MockAwareOnboardingRepository +import com.tangem.data.pay.repository.MockAwareTangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayDataMockedModule { + + @Binds + @Singleton + fun bindOnboardingRepository(repository: MockAwareOnboardingRepository): OnboardingRepository + + @Binds + @Singleton + fun bindCardDetailsRepository(repository: MockAwareTangemPayCardDetailsRepository): TangemPayCardDetailsRepository +} \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt new file mode 100644 index 0000000000..9bc64e5bcf --- /dev/null +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt @@ -0,0 +1,110 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.right +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.CustomerInfo +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** In MOCK env skips local-storage / signing enrollment; server calls go to WireMock. */ +@Singleton +internal class MockAwareOnboardingRepository @Inject constructor( + private val real: DefaultOnboardingRepository, + private val apiConfigsManager: ApiConfigsManager, +) : OnboardingRepository { + + private val mockOrderIds: MutableSet = ConcurrentHashMap.newKeySet() + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override suspend fun validateDeeplink(link: String): Either { + if (isMockMode) return true.right() + return real.validateDeeplink(link) + } + + override suspend fun isTangemPayInitialDataProduced(userWalletId: UserWalletId): Boolean { + if (isMockMode) return true + return real.isTangemPayInitialDataProduced(userWalletId) + } + + override suspend fun produceInitialData(userWalletId: UserWalletId) { + if (isMockMode) return + real.produceInitialData(userWalletId) + } + + override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either = + real.getCustomerInfo(userWalletId) + + override suspend fun createOrder(userWalletId: UserWalletId): Either { + if (isMockMode) { + mockOrderIds.add(userWalletId) + return MOCK_ORDER_ID.right() + } + return real.createOrder(userWalletId) + } + + override suspend fun clearOrderId(userWalletId: UserWalletId) { + if (isMockMode) { + mockOrderIds.remove(userWalletId) + return + } + real.clearOrderId(userWalletId) + } + + override suspend fun getOrderId(userWalletId: UserWalletId): String? { + if (isMockMode) return MOCK_ORDER_ID.takeIf { userWalletId in mockOrderIds } + return real.getOrderId(userWalletId) + } + + override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = + real.hasTangemPayInWallet(userWalletId) + + override suspend fun checkCustomerEligibility(): List { + if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS) + return real.checkCustomerEligibility() + } + + override suspend fun getCustomerEligibility(): List { + if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS) + return real.getCustomerEligibility() + } + + override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? = + real.getSavedCustomerInfo(userWalletId) + + override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean { + if (isMockMode) return false + return real.getHideMainOnboardingBanner(userWalletId) + } + + override suspend fun setHideMainOnboardingBanner(userWalletId: UserWalletId) { + if (isMockMode) return + real.setHideMainOnboardingBanner(userWalletId) + } + + override suspend fun disableTangemPay(userWalletId: UserWalletId): Either { + if (isMockMode) return Unit.right() + return real.disableTangemPay(userWalletId) + } + + override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean { + if (isMockMode) return false + return real.isTangemPayDeactivated(userWalletId) + } + + private companion object { + const val MOCK_ORDER_ID = "mock-order-id" + } +} \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt new file mode 100644 index 0000000000..fadd88988c --- /dev/null +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt @@ -0,0 +1,100 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.right +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.SetPinResult +import com.tangem.domain.pay.model.TangemPayCardBalance +import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** In MOCK env short-circuits RSA-encrypted flows (reveal/getPin/setPin) with hardcoded values. */ +@Singleton +internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( + private val real: DefaultTangemPayCardDetailsRepository, + private val apiConfigsManager: ApiConfigsManager, +) : TangemPayCardDetailsRepository { + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override suspend fun getCardBalance(userWalletId: UserWalletId): Either = + real.getCardBalance(userWalletId) + + override suspend fun revealCardDetails( + userWalletId: UserWalletId, + ): Either { + if (isMockMode) { + return TangemPayCardDetails( + pan = MOCK_PAN, + cvv = MOCK_CVV, + expirationYear = MOCK_EXPIRATION_YEAR, + expirationMonth = MOCK_EXPIRATION_MONTH, + ).right() + } + return real.revealCardDetails(userWalletId) + } + + override suspend fun getPin(userWalletId: UserWalletId, cardId: String): Either { + if (isMockMode) return MOCK_PIN.right() + return real.getPin(userWalletId, cardId) + } + + override suspend fun setPin(userWalletId: UserWalletId, pin: String): Either { + if (isMockMode) return SetPinResult.SUCCESS.right() + return real.setPin(userWalletId, pin) + } + + override suspend fun isAddToWalletDone(userWalletId: UserWalletId): Either = + real.isAddToWalletDone(userWalletId) + + override suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either = + real.setAddToWalletAsDone(userWalletId) + + override suspend fun freezeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either = real.freezeCard(userWalletId, cardId) + + override suspend fun unfreezeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either = real.unfreezeCard(userWalletId, cardId) + + override fun cardFrozenState(cardId: String): Flow = + real.cardFrozenState(cardId) + + override suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? = + real.cardFrozenStateSync(cardId) + + override suspend fun updateCardDisplayName( + cardId: String, + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either = real.updateCardDisplayName(cardId, userWalletId, displayName) + + override suspend fun updateCardLimit( + cardId: String, + userWalletId: UserWalletId, + limit: String, + ): Either = real.updateCardLimit(cardId, userWalletId, limit) + + private companion object { + const val MOCK_PAN = "4242 4242 4242 4242" + const val MOCK_CVV = "123" + const val MOCK_EXPIRATION_YEAR = "2028" + const val MOCK_EXPIRATION_MONTH = "12" + const val MOCK_PIN = "1234" + } +} \ No newline at end of file diff --git a/data/visa/src/prodDi/kotlin/com/tangem/data/pay/di/TangemPayDataProductionModule.kt b/data/visa/src/prodDi/kotlin/com/tangem/data/pay/di/TangemPayDataProductionModule.kt new file mode 100644 index 0000000000..02c95e98be --- /dev/null +++ b/data/visa/src/prodDi/kotlin/com/tangem/data/pay/di/TangemPayDataProductionModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.pay.di + +import com.tangem.data.pay.repository.DefaultOnboardingRepository +import com.tangem.data.pay.repository.DefaultTangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayDataProductionModule { + + @Binds + @Singleton + fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository + + @Binds + @Singleton + fun bindCardDetailsRepository(repository: DefaultTangemPayCardDetailsRepository): TangemPayCardDetailsRepository +} \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt index 37ac01a0fc..4608c351b3 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverterTest.kt @@ -1,9 +1,12 @@ package com.tangem.data.pay.converter import com.google.common.truth.Truth.assertThat +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.mockk import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -12,6 +15,11 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class PaymentAccountStatusValueDMConverterTest { + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk() + private val userWalletId = UserWalletId("1234567890ABCDEF") + + private val converter = PaymentAccountStatusValueDMConverter(tangemPayCurrencyFactory) + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Convert { @@ -22,7 +30,7 @@ internal class PaymentAccountStatusValueDMConverterTest { val domain = PaymentAccountStatusValue.Empty // WHEN - val result = PaymentAccountStatusValueDMConverter.convert(domain) + val result = converter.convert(domain) // THEN assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.Empty::class.java) @@ -40,7 +48,7 @@ internal class PaymentAccountStatusValueDMConverterTest { ) // WHEN - val result = PaymentAccountStatusValueDMConverter.convert(domain) + val result = converter.convert(domain) // THEN assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java) @@ -55,7 +63,7 @@ internal class PaymentAccountStatusValueDMConverterTest { val domain = PaymentAccountStatusValue.Loading // WHEN - val result = PaymentAccountStatusValueDMConverter.convert(domain) + val result = converter.convert(domain) // THEN assertThat(result).isNull() @@ -67,7 +75,7 @@ internal class PaymentAccountStatusValueDMConverterTest { val domain = PaymentAccountStatusValue.Error.Unavailable // WHEN - val result = PaymentAccountStatusValueDMConverter.convert(domain) + val result = converter.convert(domain) // THEN assertThat(result).isNull() @@ -79,7 +87,7 @@ internal class PaymentAccountStatusValueDMConverterTest { val domain = PaymentAccountStatusValue.NotCreated // WHEN - val result = PaymentAccountStatusValueDMConverter.convert(domain) + val result = converter.convert(domain) // THEN assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.NotCreated::class.java) @@ -96,7 +104,7 @@ internal class PaymentAccountStatusValueDMConverterTest { val dm = PaymentAccountStatusValueDM.Empty() // WHEN - val result = PaymentAccountStatusValueDMConverter.convertBack(dm) + val result = converter.convertBack(userWalletId, dm) // THEN assertThat(result).isEqualTo(PaymentAccountStatusValue.Empty) @@ -113,7 +121,7 @@ internal class PaymentAccountStatusValueDMConverterTest { ) // WHEN - val result = PaymentAccountStatusValueDMConverter.convertBack(dm) + val result = converter.convertBack(userWalletId, dm) // THEN assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java) @@ -126,7 +134,7 @@ internal class PaymentAccountStatusValueDMConverterTest { @Test fun `GIVEN null DM WHEN convertBack THEN returns Error Unavailable`() { // GIVEN / WHEN - val result = PaymentAccountStatusValueDMConverter.convertBack(null) + val result = converter.convertBack(userWalletId, null) // THEN assertThat(result).isEqualTo(PaymentAccountStatusValue.Error.Unavailable) @@ -138,7 +146,7 @@ internal class PaymentAccountStatusValueDMConverterTest { val dm = PaymentAccountStatusValueDM.NotCreated() // WHEN - val result = PaymentAccountStatusValueDMConverter.convertBack(dm) + val result = converter.convertBack(userWalletId, dm) // THEN assertThat(result).isEqualTo(PaymentAccountStatusValue.NotCreated) diff --git a/data/wallet-connect/build.gradle.kts b/data/wallet-connect/build.gradle.kts index b7fd3d93a1..46be3e797d 100644 --- a/data/wallet-connect/build.gradle.kts +++ b/data/wallet-connect/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { /* Project - Core */ implementation(projects.core.utils) implementation(projects.core.analytics) + api(projects.core.configToggles) /* DI */ implementation(deps.hilt.core) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index dc5c0bb1ba..59d3fc8014 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.walletconnect.DefaultWalletConnectRepository import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase +import com.tangem.data.walletconnect.network.bitcoin.WcBitcoinNetwork import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork import com.tangem.data.walletconnect.pair.* @@ -25,6 +26,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.WcRequestUseCaseFactory +import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles import com.tangem.domain.walletconnect.repository.WalletConnectRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.disconnect.WcDisconnectUseCase @@ -34,6 +36,8 @@ import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.data.walletconnect.featuretoggle.DefaultWalletConnectFeatureToggles import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -162,6 +166,22 @@ internal object WalletConnectDataModule { networksConverter = wcNetworksConverter, ) + @Provides + @Singleton + fun wcBitcoinNetwork( + @SdkMoshi moshi: Moshi, + wcNetworksConverter: WcNetworksConverter, + sessionsManager: WcSessionsManager, + factories: WcBitcoinNetwork.Factories, + walletManagersFacade: WalletManagersFacade, + ): WcBitcoinNetwork = WcBitcoinNetwork( + moshi = moshi, + sessionsManager = sessionsManager, + factories = factories, + networksConverter = wcNetworksConverter, + walletManagersFacade = walletManagersFacade, + ) + @Provides @Singleton fun caipNamespaceDelegate( @@ -198,11 +218,19 @@ internal object WalletConnectDataModule { @Provides @Singleton - fun diHelperBox(ethNetwork: WcEthNetwork, solanaNetwork: WcSolanaNetwork) = DiHelperBox( - handlers = setOf( - ethNetwork, - solanaNetwork, - ), + fun diHelperBox( + ethNetwork: WcEthNetwork, + solanaNetwork: WcSolanaNetwork, + bitcoinNetwork: WcBitcoinNetwork, + featureToggles: WalletConnectFeatureToggles, + ) = DiHelperBox( + handlers = buildSet { + add(ethNetwork) + add(solanaNetwork) + if (featureToggles.isBitcoinEnabled) { + add(bitcoinNetwork) + } + }, ) @Provides @@ -210,10 +238,15 @@ internal object WalletConnectDataModule { fun namespaceConverters( ethNamespaceConverter: WcEthNetwork.NamespaceConverter, solanaNamespaceConverter: WcSolanaNetwork.NamespaceConverter, - ): Set<@JvmSuppressWildcards WcNamespaceConverter> = setOf( - ethNamespaceConverter, - solanaNamespaceConverter, - ) + bitcoinNamespaceConverter: WcBitcoinNetwork.NamespaceConverter, + featureToggles: WalletConnectFeatureToggles, + ): Set<@JvmSuppressWildcards WcNamespaceConverter> = buildSet { + add(ethNamespaceConverter) + add(solanaNamespaceConverter) + if (featureToggles.isBitcoinEnabled) { + add(bitcoinNamespaceConverter) + } + } @Provides @Singleton @@ -239,6 +272,14 @@ internal object WalletConnectDataModule { return WcSolanaNetwork.NamespaceConverter(excludedBlockchains) } + @Provides + @Singleton + fun wcBitcoinNetworkNamespaceConverter( + excludedBlockchains: ExcludedBlockchains, + ): WcBitcoinNetwork.NamespaceConverter { + return WcBitcoinNetwork.NamespaceConverter(excludedBlockchains) + } + @Provides @Singleton fun providesWcDisconnectUseCase( @@ -248,6 +289,14 @@ internal object WalletConnectDataModule { return WcDisconnectUseCase(sessionsManager, analytics) } + @Provides + @Singleton + fun providesWalletConnectFeatureToggles( + featureTogglesManager: FeatureTogglesManager, + ): WalletConnectFeatureToggles { + return DefaultWalletConnectFeatureToggles(featureTogglesManager) + } + internal class DiHelperBox( val handlers: Set, ) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/featuretoggle/DefaultWalletConnectFeatureToggles.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/featuretoggle/DefaultWalletConnectFeatureToggles.kt new file mode 100644 index 0000000000..1f8d8184a4 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/featuretoggle/DefaultWalletConnectFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.data.walletconnect.featuretoggle + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles + +internal class DefaultWalletConnectFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : WalletConnectFeatureToggles { + + override val isBitcoinEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.WALLET_CONNECT_BITCOIN_ENABLED) +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt index 13522ffe02..00dab5a545 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt @@ -9,7 +9,7 @@ import com.reown.walletkit.client.WalletKit import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.request.DefaultWcRequestService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.utils.logging.TangemLogger diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/Model.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/Model.kt new file mode 100644 index 0000000000..2cabcd17c7 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/Model.kt @@ -0,0 +1,112 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * JSON request model for sendTransfer method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSendTransferRequest( + @Json(name = "account") + val account: String, + + @Json(name = "recipientAddress") + val recipientAddress: String, + + @Json(name = "amount") + val amount: String, + + @Json(name = "memo") + val memo: String? = null, + + @Json(name = "changeAddress") + val changeAddress: String? = null, +) + +/** + * JSON request model for getAccountAddresses method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinGetAccountAddressesRequest( + @Json(name = "account") + val account: String? = null, + + @Json(name = "intentions") + val intentions: List? = null, +) + +/** + * JSON request model for signPsbt method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSignPsbtRequest( + @Json(name = "psbt") + val psbt: String, + + @Json(name = "signInputs") + val signInputs: List, + + @Json(name = "broadcast") + val isBroadcast: Boolean? = false, +) + +/** + * JSON model for sign input specification. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSignInput( + @Json(name = "address") + val address: String, + + @Json(name = "index") + val index: Int, + + @Json(name = "sighashTypes") + val sighashTypes: List? = null, +) + +/** + * JSON request model for signMessage method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSignMessageRequest( + @Json(name = "account") + val account: String, + + @Json(name = "message") + val message: String, + + @Json(name = "address") + val address: String? = null, + + @Json(name = "protocol") + val protocol: String? = "ecdsa", +) + +/** + * JSON response model for getAccountAddresses method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinGetAccountAddressesResponse( + @Json(name = "addresses") + val addresses: List, +) + +/** + * Address information in getAccountAddresses response. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinAddressInfo( + @Json(name = "address") + val address: String, + + @Json(name = "publicKey") + val publicKey: String? = null, + + @Json(name = "path") + val path: String? = null, + + @Json(name = "intention") + val intention: String? = null, +) \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinGetAccountAddressesUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinGetAccountAddressesUseCase.kt new file mode 100644 index 0000000000..8b182c1263 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinGetAccountAddressesUseCase.kt @@ -0,0 +1,118 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.AccountAddress +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.datasource.di.SdkMoshi +import com.tangem.domain.models.network.Network +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.method.WcGetAddressesUseCase +import com.tangem.domain.walletconnect.usecase.method.WcNetworkDerivationState +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Use case for Bitcoin getAccountAddresses WalletConnect method. + * + * Returns wallet addresses filtered by intention (payment/ordinal). + * This is a non-signing operation. + */ +@JsonClass(generateAdapter = true) +internal data class AddressInfo( + @Json(name = "address") val address: String, + @Json(name = "publicKey") val publicKey: String? = null, + @Json(name = "path") val path: String? = null, + @Json(name = "intention") val intention: String? = null, +) + +internal class WcBitcoinGetAccountAddressesUseCase @AssistedInject constructor( + @Assisted val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.GetAccountAddresses, + private val walletManagersFacade: WalletManagersFacade, + private val respondService: WcRespondService, + @SdkMoshi private val moshi: Moshi, +) : WcGetAddressesUseCase { + + override val wallet get() = context.session.wallet + + override val session: WcSession + get() = context.session + override val rawSdkRequest: WcSdkSessionRequest + get() = context.rawSdkRequest + override val network: Network + get() = context.network + override val derivationState: WcNetworkDerivationState = when { + context.networkDerivationsCount > 1 -> WcNetworkDerivationState.Multiple(walletAddress = context.accountAddress) + else -> WcNetworkDerivationState.Single + } + + override suspend fun invoke(): Either { + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: return HandleMethodError.UnknownError("Failed to create wallet manager").left() + return when (val result = walletManager.getAddresses(filterOptions = method.intentions)) { + is SdkResult.Success -> { + val accountAddresses = result.data.map { addressInfo -> + AccountAddress( + address = addressInfo.address, + publicKey = addressInfo.publicKey, + path = addressInfo.derivationPath, + intention = addressInfo.metadata?.get("intention") as? String, + ) + } + val response = buildJsonResponse(accountAddresses) + respondService.respond(rawSdkRequest, response) + WcGetAddressesUseCase.GetAddressesResult( + addresses = accountAddresses.map { addr -> + WcGetAddressesUseCase.AddressInfo( + address = addr.address, + publicKey = addr.publicKey, + path = addr.path, + intention = addr.intention, + ) + }, + ).right() + } + is SdkResult.Failure -> { + HandleMethodError.UnknownError(result.error.customMessage).left() + } + } + } + + override fun reject() { + respondService.rejectRequestNonBlock(rawSdkRequest) + } + + private fun buildJsonResponse(accountAddresses: List): String { + val addresses = accountAddresses.map { addr -> + AddressInfo( + address = addr.address, + publicKey = addr.publicKey, + path = addr.path, + intention = addr.intention, + ) + } + return moshi.adapter>( + com.squareup.moshi.Types.newParameterizedType(List::class.java, AddressInfo::class.java), + ).toJson(addresses) + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcBitcoinMethod.GetAccountAddresses, + ): WcBitcoinGetAccountAddressesUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinNetwork.kt new file mode 100644 index 0000000000..38c35fbeed --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinNetwork.kt @@ -0,0 +1,214 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import com.squareup.moshi.Moshi +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.walletconnect.model.CAIP2 +import com.tangem.data.walletconnect.model.NamespaceKey +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.data.walletconnect.utils.WcNamespaceConverter +import com.tangem.data.walletconnect.utils.WcNetworksConverter +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.model.WcBitcoinMethodName +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.method.WcMethodUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import jakarta.inject.Inject + +/** + * WalletConnect request handler for Bitcoin blockchain. + * + * Handles Bitcoin-specific RPC methods: sendTransfer, getAccountAddresses, signPsbt, signMessage. + * + * @see Bitcoin RPC Reference + */ +internal class WcBitcoinNetwork( + private val moshi: Moshi, + private val sessionsManager: WcSessionsManager, + private val factories: Factories, + private val networksConverter: WcNetworksConverter, + private val walletManagersFacade: WalletManagersFacade, +) : WcRequestToUseCaseConverter { + + override fun toWcMethodName(request: WcSdkSessionRequest): WcBitcoinMethodName? { + val methodKey = request.request.method + return WcBitcoinMethodName.entries.find { it.raw == methodKey } + } + + @Suppress("CyclomaticComplexMethod") + override suspend fun toUseCase(request: WcSdkSessionRequest): Either { + fun error(message: String) = HandleMethodError.UnknownError(message).left() + + val name = toWcMethodName(request) ?: return error("Unknown method name") + val method: WcBitcoinMethod = name.toMethod(request) + .getOrElse { return error(it.message.orEmpty()) } + ?: return error("Failed to parse $name") + + val session = sessionsManager.findSessionByTopic(request.topic) + ?: return HandleMethodError.UnknownSession.left() + + val wallet = session.wallet + val chainId = request.chainId.orEmpty() + + val account = session.account + + suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest( + rawChainId = chainId, + account = account, + ) + + suspend fun anyAddress() = anyExistNetwork() + ?.let { network -> walletManagersFacade.getDefaultAddress(wallet.walletId, network).orEmpty() } + .orEmpty() + + val accountAddress = when (method) { + is WcBitcoinMethod.SendTransfer -> method.account + is WcBitcoinMethod.GetAccountAddresses -> method.account + is WcBitcoinMethod.SignPsbt -> method.signInputs.firstOrNull()?.address ?: anyAddress() + is WcBitcoinMethod.SignMessage -> method.address ?: method.account + } + + val walletNetwork = networksConverter + .findWalletNetworkForRequest(request, session, accountAddress) + ?: anyExistNetwork() + ?: return error("Failed to find walletNetwork for accountAddress $accountAddress") + + val context = WcMethodUseCaseContext( + session = session, + rawSdkRequest = request, + network = walletNetwork, + accountAddress = accountAddress, + networkDerivationsCount = networksConverter.filterWalletNetworkForRequest( + rawChainId = chainId, + account = account, + ).size, + ) + + val useCase = when (method) { + is WcBitcoinMethod.SendTransfer -> factories.sendTransfer.create(context, method) + is WcBitcoinMethod.GetAccountAddresses -> factories.getAccountAddresses.create(context, method) + is WcBitcoinMethod.SignPsbt -> factories.signPsbt.create(context, method) + is WcBitcoinMethod.SignMessage -> factories.signMessage.create(context, method) + } + return useCase.right() + } + + private fun WcBitcoinMethodName.toMethod(request: WcSdkSessionRequest): Either { + val rawParams = request.request.params + return when (this) { + WcBitcoinMethodName.SendTransfer -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.SendTransfer( + account = req.account, + recipientAddress = req.recipientAddress, + amount = req.amount, + memo = req.memo, + changeAddress = req.changeAddress, + ) + } + WcBitcoinMethodName.GetAccountAddresses -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.GetAccountAddresses( + account = req.account.orEmpty(), + intentions = req.intentions, + ) + } + WcBitcoinMethodName.SignPsbt -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.SignPsbt( + psbt = req.psbt, + signInputs = req.signInputs.map { input -> + WcBitcoinMethod.SignInput( + address = input.address, + index = input.index, + sighashTypes = input.sighashTypes, + ) + }, + shouldBroadcast = req.isBroadcast == true, + ) + } + WcBitcoinMethodName.SignMessage -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.SignMessage( + account = req.account, + message = req.message, + address = req.address, + protocol = req.protocol ?: "ecdsa", + ) + } + }.right() + } + + /** + * Bitcoin namespace converter for CAIP-2 chain IDs. + * + * Bitcoin uses BIP-122 namespace with genesis block hash as reference. + * Example: bip122:000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f + */ + internal class NamespaceConverter @Inject constructor( + override val excludedBlockchains: ExcludedBlockchains, + ) : WcNamespaceConverter { + + override val namespaceKey: NamespaceKey = NamespaceKey(NAMESPACE) + + override fun toBlockchain(chainId: CAIP2): Blockchain? { + if (chainId.namespace != namespaceKey.key) return null + return when { + isMainnetReference(chainId.reference) -> Blockchain.Bitcoin + isTestnetReference(chainId.reference) -> Blockchain.BitcoinTestnet + else -> null + } + } + + private fun isMainnetReference(reference: String): Boolean { + return MAINNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } || + reference.equals("mainnet", ignoreCase = true) + } + + private fun isTestnetReference(reference: String): Boolean { + return TESTNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } || + reference.equals("testnet", ignoreCase = true) + } + + companion object { + private const val NAMESPACE = "bip122" + + // Bitcoin mainnet genesis hash prefixes (supports any truncated version) + private val MAINNET_GENESIS_PREFIX = listOf( + "000000000019d6689c085ae165831e93", // Mainnet genesis hash prefix (min 32 chars for uniqueness) + ) + + // Bitcoin testnet genesis hash prefixes (supports any truncated version) + private val TESTNET_GENESIS_PREFIX = listOf( + "000000000933ea01ad0ee984209779ba", // Standard testnet genesis hash prefix (9 leading zeros) + "0000000000933ea01ad0ee984209779ba", // Alternative testnet prefix (10 leading zeros) + ) + } + } + + /** + * Factory classes for creating Bitcoin WalletConnect use cases. + */ + internal class Factories @Inject constructor( + val sendTransfer: WcBitcoinSendTransferUseCase.Factory, + val getAccountAddresses: WcBitcoinGetAccountAddressesUseCase.Factory, + val signPsbt: WcBitcoinSignPsbtUseCase.Factory, + val signMessage: WcBitcoinSignMessageUseCase.Factory, + ) + + companion object { + private const val NAMESPACE = "bip122" + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSendTransferUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSendTransferUseCase.kt new file mode 100644 index 0000000000..0ded51f956 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSendTransferUseCase.kt @@ -0,0 +1,146 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.left +import com.tangem.blockchain.blockchains.bitcoin.BitcoinTransactionExtras +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.SignCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate +import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.walletconnect.WcTransactionSignerProvider +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck +import com.tangem.domain.walletconnect.usecase.method.WcMutableFee +import com.tangem.domain.walletconnect.usecase.method.WcSignState +import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +/** + * Use case for Bitcoin sendTransfer WalletConnect method. + * + * Sends a Bitcoin transfer transaction with optional memo (OP_RETURN) and custom change address. + */ +@Suppress("LongParameterList") +internal class WcBitcoinSendTransferUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.SendTransfer, + private val walletManagersFacade: WalletManagersFacade, + private val signerProvider: WcTransactionSignerProvider, + override val respondService: WcRespondService, + override val analytics: AnalyticsEventHandler, + blockAidDelegate: BlockAidVerificationDelegate, +) : BaseWcSignUseCase(), + WcTransactionUseCase, + WcMutableFee { + + override val wallet get() = context.session.wallet + + private val transferAmount: Amount by lazy { + createAmountFromSatoshis(method.amount) + } + + override val securityStatus: LceFlow = + blockAidDelegate.getSecurityStatus( + network = network, + method = method, + rawSdkRequest = rawSdkRequest, + session = session, + accountAddress = context.accountAddress, + ).map { lce -> + lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } + } + + override suspend fun SignCollector.onSign(state: WcSignState) { + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: run { + emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left())) + return + } + + val signer = signerProvider.createSigner(wallet) + when (val result = walletManager.send(state.signModel, signer)) { + is SdkResult.Success -> { + val response = buildJsonResponse(result.data.hash) + val wcRespondResult = respondService.respond(rawSdkRequest, response) + emit(state.toResult(wcRespondResult)) + } + is SdkResult.Failure -> { + emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left())) + } + } + } + + override suspend fun FlowCollector.onMiddleAction( + signModel: TransactionData, + action: WcBitcoinTxAction, + ) { + val uncompiled = signModel as? TransactionData.Uncompiled ?: return + val newState = when (action) { + is WcBitcoinTxAction.UpdateFee -> uncompiled.copy(fee = action.fee) + } + emit(newState) + } + + override suspend fun dAppFee(): Fee? = null + + override fun updateFee(fee: Fee) { + middleAction(WcBitcoinTxAction.UpdateFee(fee)) + } + + override fun invoke(): Flow> = flow { + val fee = dAppFee() + val transactionData = createTransactionData(fee) + emitAll(delegate.invoke(transactionData)) + } + + private fun createTransactionData(fee: Fee?): TransactionData.Uncompiled { + return TransactionData.Uncompiled( + amount = transferAmount, + fee = fee, + sourceAddress = context.accountAddress, + destinationAddress = method.recipientAddress, + extras = BitcoinTransactionExtras( + memo = method.memo, + changeAddress = method.changeAddress, + ), + ) + } + + private fun createAmountFromSatoshis(satoshis: String): Amount { + val btcValue = BigDecimal(satoshis).divide(SATOSHI_IN_BTC) + return Amount( + currencySymbol = network.currencySymbol, + value = btcValue, + decimals = BITCOIN_DECIMALS, + ) + } + + private fun buildJsonResponse(txid: String): String = "{\"txid\":\"$txid\"}" + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SendTransfer): WcBitcoinSendTransferUseCase + } + + private companion object { + val SATOSHI_IN_BTC = BigDecimal("100000000") + const val BITCOIN_DECIMALS = 8 + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignMessageUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignMessageUseCase.kt new file mode 100644 index 0000000000..0f60af6c1e --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignMessageUseCase.kt @@ -0,0 +1,117 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.left +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.SignCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.datasource.di.SdkMoshi +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.SimulationResult +import com.domain.blockaid.models.transaction.ValidationResult +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.walletconnect.WcTransactionSignerProvider +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase +import com.tangem.domain.walletconnect.usecase.method.WcSignState +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * Use case for Bitcoin signMessage WalletConnect method. + * + * Signs an arbitrary message using Bitcoin message signing format (BIP-137 ECDSA). + */ +@JsonClass(generateAdapter = true) +internal data class SignMessageResponse( + @Json(name = "address") val address: String, + @Json(name = "signature") val signature: String, + @Json(name = "messageHash") val messageHash: String? = null, +) + +@Suppress("LongParameterList") +internal class WcBitcoinSignMessageUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.SignMessage, + private val walletManagersFacade: WalletManagersFacade, + private val signerProvider: WcTransactionSignerProvider, + override val respondService: WcRespondService, + override val analytics: AnalyticsEventHandler, + @SdkMoshi private val moshi: Moshi, +) : BaseWcSignUseCase(), + WcMessageSignUseCase { + + override val wallet get() = context.session.wallet + + // BlockAid doesn't support Bitcoin message signing + override val securityStatus: LceFlow = flowOf( + Lce.Content( + CheckTransactionResult( + validation = ValidationResult.FAILED_TO_VALIDATE, + simulation = SimulationResult.FailedToSimulate, + ), + ), + ) + + override suspend fun SignCollector.onSign( + state: WcSignState, + ) { + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: run { + emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left())) + return + } + + val signer = signerProvider.createSigner(wallet) + + // Use the address from method, or fallback to account if not specified + val addressToSign = method.address ?: method.account + + // Use MessageSigner to sign the message + when (val result = walletManager.signMessage( + message = method.message, + address = addressToSign, + protocol = method.protocol, + signer = signer, + )) { + is SdkResult.Success -> { + val response = buildJsonResponse(result.data) + val wcRespondResult = respondService.respond(rawSdkRequest, response) + emit(state.toResult(wcRespondResult)) + } + is SdkResult.Failure -> { + emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left())) + } + } + } + + override fun invoke(): Flow> { + return delegate.invoke(initModel = WcMessageSignUseCase.SignModel(method.message)) + } + + private fun buildJsonResponse(data: com.tangem.blockchain.common.messagesigning.MessageSignatureResult): String { + val response = SignMessageResponse( + address = data.address, + signature = data.signature, + messageHash = data.messageHash, + ) + return moshi.adapter(SignMessageResponse::class.java).toJson(response) + } + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignMessage): WcBitcoinSignMessageUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignPsbtUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignPsbtUseCase.kt new file mode 100644 index 0000000000..302376444a --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignPsbtUseCase.kt @@ -0,0 +1,142 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.left +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.SignInput +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.SignCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate +import com.tangem.datasource.di.SdkMoshi +import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.walletconnect.WcTransactionSignerProvider +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck +import com.tangem.domain.walletconnect.usecase.method.WcSignState +import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Use case for Bitcoin signPsbt WalletConnect method. + * + * Signs a Partially Signed Bitcoin Transaction (BIP-174 PSBT) with optional broadcast. + */ +@JsonClass(generateAdapter = true) +internal data class SignPsbtResponse( + @Json(name = "psbt") val psbt: String, + @Json(name = "txid") val txid: String? = null, +) + +@Suppress("LongParameterList") +internal class WcBitcoinSignPsbtUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.SignPsbt, + private val walletManagersFacade: WalletManagersFacade, + private val signerProvider: WcTransactionSignerProvider, + override val respondService: WcRespondService, + override val analytics: AnalyticsEventHandler, + blockAidDelegate: BlockAidVerificationDelegate, + @SdkMoshi private val moshi: Moshi, +) : BaseWcSignUseCase(), + WcTransactionUseCase { + + override val wallet get() = context.session.wallet + + override val securityStatus: LceFlow = + blockAidDelegate.getSecurityStatus( + network = network, + method = method, + rawSdkRequest = rawSdkRequest, + session = session, + accountAddress = context.accountAddress, + ).map { lce -> + lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } + } + + override suspend fun SignCollector.onSign(state: WcSignState) { + // Update wallet manager to refresh UTXO data before processing Bitcoin transaction + walletManagersFacade.update( + userWalletId = wallet.walletId, + network = network, + extraTokens = emptySet(), + ) + + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: run { + emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left())) + return + } + + val signer = signerProvider.createSigner(wallet) + val signInputs = method.signInputs.map { input -> + SignInput( + address = input.address, + index = input.index, + sighashTypes = input.sighashTypes, + ) + } + val signedPsbtResult = walletManager.signPsbt( + psbtBase64 = method.psbt, + signInputs = signInputs, + signer = signer, + ) + + when (signedPsbtResult) { + is SdkResult.Success -> { + val signedPsbt = signedPsbtResult.data + val txid = if (method.shouldBroadcast) { + when (val broadcastResult = walletManager.broadcastPsbt(signedPsbt)) { + is SdkResult.Success -> broadcastResult.data + is SdkResult.Failure -> { + val error = HandleMethodError.UnknownError(broadcastResult.error.customMessage).left() + emit(state.toResult(error)) + return + } + } + } else { + null + } + + val response = buildJsonResponse(signedPsbt, txid) + val wcRespondResult = respondService.respond(rawSdkRequest, response) + emit(state.toResult(wcRespondResult)) + } + is SdkResult.Failure -> { + emit(state.toResult(HandleMethodError.UnknownError(signedPsbtResult.error.customMessage).left())) + } + } + } + + override fun invoke(): Flow> { + val transactionData = TransactionData.Compiled( + value = TransactionData.Compiled.Data.RawString(method.psbt), + ) + return delegate.invoke(transactionData) + } + + private fun buildJsonResponse(signedPsbt: String, txid: String?): String { + val response = SignPsbtResponse( + psbt = signedPsbt, + txid = txid, + ) + return moshi.adapter(SignPsbtResponse::class.java).toJson(response) + } + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignPsbt): WcBitcoinSignPsbtUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinTxAction.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinTxAction.kt new file mode 100644 index 0000000000..cb00b43745 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinTxAction.kt @@ -0,0 +1,8 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import com.tangem.blockchain.common.transaction.Fee + +sealed interface WcBitcoinTxAction { + + data class UpdateFee(val fee: Fee) : WcBitcoinTxAction +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt index 6347e2f390..1a09c57487 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt @@ -2,7 +2,7 @@ package com.tangem.data.walletconnect.network.solana import com.tangem.blockchain.extensions.decodeBase58 import com.tangem.blockchain.extensions.encodeBase64NoWrap -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.utils.converter.Converter import com.tangem.utils.logging.TangemLogger import javax.inject.Inject diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index 02ce4d738f..c7e868cf94 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -8,7 +8,7 @@ import com.domain.blockaid.models.dapp.CheckDAppResult import com.domain.blockaid.models.dapp.DAppData import com.reown.walletkit.client.Wallet import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.walletconnect.WcAnalyticEvents diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt index a747091d4a..d5aa2c0090 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -5,7 +5,7 @@ import arrow.core.left import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.datasource.local.walletconnect.WalletConnectStore diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt index 130530c326..80d677fed7 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt @@ -1,8 +1,9 @@ package com.tangem.data.walletconnect.request import com.reown.walletkit.client.Wallet +import com.tangem.data.walletconnect.BuildConfig import com.tangem.data.walletconnect.respond.DefaultWcRespondService -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter import com.tangem.data.walletconnect.utils.getDappOriginUrl @@ -43,6 +44,7 @@ internal class DefaultWcRequestService( respondService.rejectRequestNonBlock(sr) if (name.raw.startsWith("wallet_")) return } + _wcRequest.trySend(name to sr) } @@ -62,6 +64,11 @@ internal class DefaultWcRequestService( } private fun saveRequest(request: WcSdkSessionRequest) { + // Skip caching in debug builds since filtering is disabled + if (BuildConfig.DEBUG) { + return + } + val hash = respondService.sessionRequestHash(request) val now = DateTime.now().millis respondService.cachedRequest.update { it + (now to hash) } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt index 41caf2afe1..7352709bd1 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt @@ -4,7 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.WcRequestUseCaseFactory diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt index fdb3bf7bb1..ea4645374e 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt @@ -7,7 +7,7 @@ import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.common.extensions.calculateSha256 import com.tangem.common.extensions.toHexString -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.domain.walletconnect.model.WcRequestError import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest import com.tangem.utils.logging.TangemLogger diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt index c6fed93088..7d03acdda4 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -6,7 +6,7 @@ import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcNetworksConverter import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionConverter diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt index c8491f5be6..410b86fca9 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt @@ -9,7 +9,7 @@ internal object BlockAidChainNameConverter : Converter { @Suppress("CyclomaticComplexMethod") override fun convert(value: Network): String? { - return when (Blockchain.fromNetworkId(value.backendId)) { + return when (Blockchain.fromNetworkId(value.rawId)) { Blockchain.Arbitrum -> "arbitrum" Blockchain.Avalanche -> "avalanche" Blockchain.AvalancheTestnet -> "avalanche-fuji" @@ -29,6 +29,9 @@ internal object BlockAidChainNameConverter : Converter { Blockchain.Solana -> "mainnet" + Blockchain.Bitcoin -> "bitcoin" + Blockchain.BitcoinTestnet -> "bitcoin-testnet" + else -> null } } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt index f0d71c240c..2f3ddc858d 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt @@ -6,6 +6,7 @@ import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.models.network.Network +import com.tangem.domain.walletconnect.model.WcBitcoinMethod import com.tangem.domain.walletconnect.model.WcEthMethod import com.tangem.domain.walletconnect.model.WcMethod import com.tangem.domain.walletconnect.model.WcSession @@ -26,10 +27,8 @@ internal class BlockAidVerificationDelegate @Inject constructor( session: WcSession, accountAddress: String?, ): LceFlow = flow { - val failedResult = CheckTransactionResult( - validation = ValidationResult.FAILED_TO_VALIDATE, - simulation = SimulationResult.FailedToSimulate, - ) + val failedResult = createFailedResult() + if (accountAddress.isNullOrEmpty()) { emit(Lce.Content(failedResult)) return@flow @@ -43,18 +42,22 @@ internal class BlockAidVerificationDelegate @Inject constructor( val methodName = when (method) { is WcEthMethod -> rawSdkRequest.request.method is WcSolanaMethod -> method.trimmedPrefixMethodName + is WcBitcoinMethod -> rawSdkRequest.request.method is WcMethod.Unsupported -> { emit(Lce.Content(failedResult)) return@flow } } + val params = when (method) { is WcEthMethod -> TransactionParams.Evm(rawSdkRequest.request.params) is WcSolanaMethod.SignAllTransaction -> TransactionParams.Solana(method.transaction) is WcSolanaMethod.SignTransaction -> TransactionParams.Solana(listOf(method.transaction)) - is WcSolanaMethod.SignMessage -> { - // BlockAid doesn't support solana_signMessage - emit(Lce.Content(failedResult)) + is WcSolanaMethod.SignMessage, + is WcBitcoinMethod, + -> { + // BlockAid doesn't support Solana message signing and Bitcoin methods + emit(Lce.Content(createSafeResult())) return@flow } else -> { @@ -81,4 +84,14 @@ internal class BlockAidVerificationDelegate @Inject constructor( }, ) } + + private fun createFailedResult() = CheckTransactionResult( + validation = ValidationResult.FAILED_TO_VALIDATE, + simulation = SimulationResult.FailedToSimulate, + ) + + private fun createSafeResult() = CheckTransactionResult( + validation = ValidationResult.SAFE, + simulation = SimulationResult.FailedToSimulate, + ) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt index 8ef4d7a69e..b295d0356d 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt @@ -4,6 +4,7 @@ import com.reown.walletkit.client.Wallet import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.common.currency.isCustomCoin import com.tangem.data.walletconnect.model.CAIP10 import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer @@ -84,7 +85,7 @@ internal class WcNetworksConverter @Inject constructor( val blockchain = namespaceConverters .firstNotNullOfOrNull { it.toBlockchain(rawChainId) } ?: return listOf() - val allCoinNetwork = portfolioNetworks.filter { it.rawId == blockchain.id } + val allCoinNetwork = portfolioNetworks.filter { it.rawId == blockchain.toNetworkId() } return allCoinNetwork } @@ -103,7 +104,7 @@ internal class WcNetworksConverter @Inject constructor( ?: return@mapNotNullTo null portfolioNetworks // find all derivation - .filter { it.rawId == blockchain.id } + .filter { it.rawId == blockchain.toNetworkId() } // find equal address .firstOrNull { network -> val walletAddress = getAddressForWC(wallet.walletId, network) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt index 06637ecefd..4c70570c4d 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt @@ -3,8 +3,6 @@ package com.tangem.data.walletconnect.utils import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -const val WC_TAG = "Wallet Connect" - internal interface WcSdkObserver : WalletKit.WalletDelegate { override val onSessionAuthenticate: ((Wallet.Model.SessionAuthenticate, Wallet.Model.VerifyContext) -> Unit)? diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 117bfacb59..3a0617f596 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -19,7 +19,6 @@ import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.SimpleResult import com.tangem.blockchain.nft.models.NFTAsset import com.tangem.blockchain.nft.models.NFTCollection -import com.tangem.blockchain.tokenbalance.models.TokenBalance import com.tangem.blockchain.transactionhistory.models.TransactionHistoryRequest import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory import com.tangem.blockchainsdk.BlockchainSDKFactory @@ -60,7 +59,7 @@ import java.util.EnumSet import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -@Suppress("LargeClass", "TooManyFunctions") +@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") internal class DefaultWalletManagersFacade @Inject constructor( private val walletManagersStore: WalletManagersStore, private val userWalletsListRepository: UserWalletsListRepository, @@ -86,6 +85,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( userWalletId: UserWalletId, network: Network, extraTokens: Set, + xpub: String?, ): UpdateWalletManagerResult { val userWallet = getUserWallet(userWalletId) val blockchain = network.toBlockchain() @@ -96,6 +96,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( blockchain = blockchain, derivationPath = derivationPath, extraTokens = extraTokens, + xpub = xpub, ) } @@ -208,11 +209,14 @@ internal class DefaultWalletManagersFacade @Inject constructor( "Unable to get a wallet manager for blockchain: $blockchain" } - val address = walletManager - .wallet - .addresses - .find { it.type == addressType } - ?.value ?: walletManager.wallet.address + val isDynamicAddressesEnabled = (walletManager as? DynamicAddressesManager)?.isDynamicAddressesEnabled == true + + val address = if (isDynamicAddressesEnabled) { + getDynamicAddressesLastUsedReceiveAddress(userWalletId, network) ?: walletManager.wallet.address + } else { + walletManager.wallet.addresses.find { it.type == addressType }?.value ?: walletManager.wallet.address + } + return blockchain.getExploreUrl(address, contractAddress) } @@ -310,6 +314,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( blockchain: Blockchain, derivationPath: String?, extraTokens: Set, + xpub: String? = null, ): UpdateWalletManagerResult { if (derivationPath != null && !userWallet.hasDerivation(blockchain, derivationPath)) { TangemLogger.w("Derivation missed for: $blockchain") @@ -327,6 +332,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( } val isUpdated = updateWalletManagerTokensIfNeeded(walletManager, extraTokens) + if (xpub != null) restoreXpubModeIfNeeded(walletManager, xpub) return try { if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { @@ -436,12 +442,8 @@ internal class DefaultWalletManagersFacade @Inject constructor( ) } - try { - walletManager.enableDynamicAddresses(xpub) - SimpleResult.Success - } catch (e: Exception) { - SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to enable XPUB mode")) - } + walletManager.enableDynamicAddresses(xpub) + SimpleResult.Success } @Suppress("TooGenericExceptionCaught") @@ -455,17 +457,16 @@ internal class DefaultWalletManagersFacade @Inject constructor( ) } - try { - walletManager.disableDynamicAddresses() - SimpleResult.Success - } catch (e: Exception) { - SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to disable XPUB mode")) - } + walletManager.disableDynamicAddresses() + SimpleResult.Success } + override suspend fun isDynamicAddressesEnabled(userWalletId: UserWalletId, network: Network): Boolean { + return getEnabledDynamicAddressesManagerOrNull(userWalletId, network) != null + } + override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? { - val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) - val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return null + val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address } @@ -473,25 +474,23 @@ internal class DefaultWalletManagersFacade @Inject constructor( userWalletId: UserWalletId, network: Network, ): String? { - val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) - val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return null + val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null return dynamicAddressesManager.usedAddresses - .filter { usedAddress -> - val nodes = runCatching { DerivationPath(usedAddress.path).nodes }.getOrNull() - ?: return@filter false - nodes.size >= XPUB_PATH_MIN_NODES && nodes[nodes.size - 2].index == RECEIVE_CHAIN_INDEX + .mapNotNull { usedAddress -> + val nodes = runCatching { DerivationPath(usedAddress.derivationPath).nodes }.getOrNull() + ?: return@mapNotNull null + if (nodes.size < XPUB_PATH_MIN_NODES) return@mapNotNull null + if (nodes[nodes.size - 2].index != RECEIVE_CHAIN_INDEX) return@mapNotNull null + usedAddress to nodes.last().index } - .maxByOrNull { usedAddress -> - runCatching { DerivationPath(usedAddress.path).nodes.last().index }.getOrDefault(0L) - } - ?.address + .maxByOrNull { (_, lastIndex) -> lastIndex } + ?.first?.address } override suspend fun hasDynamicAddressesNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean { - val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) - val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return false + val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return false return dynamicAddressesManager.usedAddresses.any { usedAddress -> - val nodes = runCatching { DerivationPath(usedAddress.path).nodes }.getOrNull() + val nodes = runCatching { DerivationPath(usedAddress.derivationPath).nodes }.getOrNull() ?: return@any false val isBaseAddress = nodes.size >= XPUB_PATH_MIN_NODES && nodes[nodes.size - 2].index == RECEIVE_CHAIN_INDEX && @@ -500,6 +499,45 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + override suspend fun probeHasFundsOnAdditionalAddresses( + userWalletId: UserWalletId, + network: Network, + xpub: String, + ): Boolean { + return withContext(dispatchers.io) { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + val dynamicAddressesManager = walletManager as? DynamicAddressesManager + ?: return@withContext false + when (val result = dynamicAddressesManager.probeHasFundsOnNonBaseAddresses(xpub)) { + is Result.Success -> result.data + is Result.Failure -> { + TangemLogger.w("Xpub probe failed for ${network.id}: ${result.error}") + false + } + } + } + } + + private suspend fun getEnabledDynamicAddressesManagerOrNull( + userWalletId: UserWalletId, + network: Network, + ): DynamicAddressesManager? { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + return (walletManager as? DynamicAddressesManager)?.takeIf { it.isDynamicAddressesEnabled } + } + + private fun restoreXpubModeIfNeeded(walletManager: WalletManager, xpub: String) { + val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return + if (dynamicAddressesManager.isDynamicAddressesEnabled) return + + try { + dynamicAddressesManager.enableDynamicAddresses(xpub) + TangemLogger.i("Restored XPUB mode for ${walletManager.wallet.blockchain}") + } catch (e: Exception) { + TangemLogger.e("Failed to restore XPUB mode: ${e.message}") + } + } + // endregion Dynamic Addresses @Deprecated("Will be removed in future") @@ -850,17 +888,6 @@ internal class DefaultWalletManagersFacade @Inject constructor( return blockchain.getNFTExploreUrl(assetIdentifier) } - override suspend fun getTokenBalances(userWalletId: UserWalletId, network: Network): List { - val blockchain = network.toBlockchain() - val walletManager = getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = blockchain, - derivationPath = network.derivationPath.value, - ) ?: return emptyList() - val address = walletManager.wallet.address - return walletManager.getTokenBalances(address) - } - override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean { val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 5a5278632e..f748ce863f 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -24,6 +24,7 @@ import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.models.UserWalletRemoteInfo +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -118,8 +119,13 @@ internal class DefaultWalletsRepository( } } - override suspend fun createWallet(userWalletId: UserWalletId) { - walletServerBinder.bind(userWalletId) + override suspend fun createWallet(userWalletId: UserWalletId): WalletSyncResult { + val response = walletServerBinder.bind(userWalletId) ?: return WalletSyncResult.AlreadyExists + return if (response is ApiResponse.Success && response.code == HttpException.Code.CREATED) { + WalletSyncResult.Created + } else { + WalletSyncResult.AlreadyExists + } } override fun nftEnabledStatus(userWalletId: UserWalletId): Flow = appPreferencesStore diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt index c347bb1e54..3628a45990 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.wallets.derivations import arrow.core.getOrElse import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict @@ -76,6 +77,23 @@ internal class DefaultDerivationsRepository @Inject constructor( } } + override suspend fun getExistingDerivedKeys( + userWalletId: UserWalletId, + seedKey: ByteArrayKey, + ): ExtendedPublicKeysMap { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + return userWallet.getExistingDerivedKeys()[seedKey] ?: ExtendedPublicKeysMap(emptyMap()) + } + + private fun UserWallet.getExistingDerivedKeys(): Map { + return when (this) { + is UserWallet.Cold -> scanResponse.derivedKeys + is UserWallet.Hot -> wallets + ?.associate { it.publicKey.toMapKey() to ExtendedPublicKeysMap(it.derivedKeys) } + .orEmpty() + } + } + override suspend fun hasMissedDerivations( userWalletId: UserWalletId, networksWithDerivationPath: Map, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt index 9c4c0af642..1b0b9f93b2 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt @@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.KeyWalletPublicKey @@ -140,11 +141,12 @@ class MissedDerivationsFinder private constructor( * - Account-level (e.g. m/84'/0'/0') — the XPUB itself * - Parent (e.g. m/84'/0') — needed for parent fingerprint in XPUB serialization * - * Only applicable for BIP44-style XPUB blockchains (BTC, BCH, LTC, DOGE, DASH, RVN). + * Only applicable for blockchains listed in [DynamicAddressesSupportedBlockchains] + * (BTC/LTC via BIP-84 SegWit, BCH/DOGE/DASH/RVN via BIP-44, plus their testnets). */ private fun Blockchain.getXpubDerivationPaths(derivationPath: DerivationPath): List { if (!isDynamicAddressesEnabled) return emptyList() - if (!isBip44DerivationStyleXPUB()) return emptyList() + if (!DynamicAddressesSupportedBlockchains.isSupported(this)) return emptyList() val nodes = derivationPath.nodes if (nodes.size < XPUB_MIN_NODES) return emptyList() diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt index b0285167fc..dea2d7df49 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt @@ -132,6 +132,73 @@ internal class MissedDerivationsFinderTest { Truth.assertThat(actual).isEmpty() } + @Test + fun `XPUB derivations added for supported blockchain when dynamic addresses enabled`() { + val userWallet = MockUserWalletFactory.create(createWallet2ScanResponse()) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = true) + + val currencies = listOf(MockCryptoCurrencyFactory(userWallet).createCoin(Blockchain.Bitcoin)) + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf( + DerivationPath("m/84'/0'/0'/0/0"), // Bitcoin BIP-84 default + DerivationPath("m/84'/0'/0'"), // XPUB account-level path + DerivationPath("m/84'/0'"), // Parent (for XPUB fingerprint) + DerivationPath("m/44'/60'/0'/0/0"), // Ethereum added by enrichBlockchains + ), + ) + } + + @Test + fun `XPUB derivations NOT added for supported blockchain when dynamic addresses disabled`() { + val userWallet = MockUserWalletFactory.create(createWallet2ScanResponse()) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) + + val currencies = listOf(MockCryptoCurrencyFactory(userWallet).createCoin(Blockchain.Bitcoin)) + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf( + DerivationPath("m/84'/0'/0'/0/0"), + DerivationPath("m/44'/60'/0'/0/0"), + ), + ) + } + + @Test + fun `XPUB derivations NOT added for unsupported blockchain when dynamic addresses enabled`() { + val userWallet = MockUserWalletFactory.create(createWallet2ScanResponse()) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = true) + + val currencies = listOf(MockCryptoCurrencyFactory(userWallet).createCoin(Blockchain.Ethereum)) + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf(DerivationPath("m/44'/60'/0'/0/0")), + ) + } + + /** + * Wallet2 config yields DerivationStyle.V3 (BIP-84 SegWit for BTC/LTC) — the style the + * Dynamic Addresses feature actually targets. [MockScanResponseFactory] hardcodes + * `isHDWalletAllowed = false` for Wallet2, so we patch it to `true` to mirror production + * scans that reach [MissedDerivationsFinder]. + */ + private fun createWallet2ScanResponse() = MockScanResponseFactory.create( + cardConfig = Wallet2CardConfig, + derivedKeys = emptyMap(), + ).let { + it.copy( + card = it.card.copy( + settings = it.card.settings.copy(isHDWalletAllowed = true, isBackupAllowed = true), + ), + ) + } + @Test fun `derivations ONLY for never derived currencies`() { val scanResponse = MockScanResponseFactory.create( diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index 22f5b7a42e..e8f613b613 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -43,7 +43,7 @@ internal class DefaultYieldSupplyRepository( private val statusMapFlow = MutableStateFlow>(emptyMap()) - override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { + override suspend fun getCachedMarkets(): List = withContext(dispatchers.io) { val cache = store.getSyncOrNull().orEmpty() val domain = cache.map(YieldMarketTokenConverter::convert) domain.enrichNetworkIds() @@ -62,14 +62,14 @@ internal class DefaultYieldSupplyRepository( } override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketToken { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") val response = yieldSupplyApi.getYieldTokenStatus(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() return YieldMarketTokenConverter.convert(response) } override suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") val response = yieldSupplyApi.getYieldTokenChart(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() return YieldTokenChartConverter.convert(response) @@ -99,7 +99,7 @@ internal class DefaultYieldSupplyRepository( cryptoCurrencyToken: CryptoCurrency.Token, address: String, ): Boolean = withContext(dispatchers.io) { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") yieldSupplyApi.activateYieldModule( body = YieldSupplyChangeTokenStatusBody( @@ -113,7 +113,7 @@ internal class DefaultYieldSupplyRepository( override suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token, address: String): Boolean = withContext(dispatchers.io) { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") yieldSupplyApi.deactivateYieldModule( YieldSupplyChangeTokenStatusBody( diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt index cf8174d299..310c568308 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -29,13 +29,14 @@ import com.tangem.blockchain.yieldsupply.providers.YieldSupplyStatus as SDKYield @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultYieldSupplyTransactionRepositoryTest { - private val networkId = Network.ID(value = "ETH/test", derivationPath = Network.DerivationPath.None) + private val networkId = Network.ID(value = "ethereum/test", derivationPath = Network.DerivationPath.None) private val mockedContractAddress = "0x000000000000000000000000000000000000" private val yieldContractAddress = "0x1234" private val userWalletId = mockk() private val cryptoCurrency = mockk(relaxed = true) { every { network.id } returns networkId + every { network.rawId } returns networkId.rawId.value every { contractAddress } returns mockedContractAddress } private val cryptoCurrencyStatus = mockk(relaxed = true) { diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt index d493fc48c5..85c362e718 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt @@ -12,4 +12,13 @@ interface AccountsExpandedRepository { suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) suspend fun clearStore() suspend fun update(accountState: AccountExpandedState) + + interface Factory { + fun create(storeFileName: String): AccountsExpandedRepository + } + + companion object { + const val MAIN_STORE_FILE_NAME = "account_expanded_store" + const val CHOOSE_TOKEN_FILE_NAME = "choose_token_account_expanded_store" + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt index de9a515a80..b3bc1bf1c0 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt @@ -80,7 +80,7 @@ class ArchiveCryptoPortfolioUseCase( val hasNotReferralToken = statuses.none { status -> val currency = status.currency - currency.network.backendId == referralToken.networkId && + currency.network.rawId == referralToken.networkId && (currency as? CryptoCurrency.Token)?.contractAddress == referralToken.contractAddress && status.value.networkAddress?.availableAddresses?.any { it.value == address } == true } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt index 6d858dc872..a05eb27cd4 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt @@ -81,7 +81,6 @@ class IsAccountsModeEnabledUseCase( is PaymentAccountStatusValue.IssuingCard, is PaymentAccountStatusValue.Loaded, is PaymentAccountStatusValue.Loading, - is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, is PaymentAccountStatusValue.Deactivated, -> true diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 3561434daf..82f735a3c9 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -168,7 +168,7 @@ class ManageCryptoCurrenciesUseCase( val foundToken = accountStatus.tokenList.flattenCurrencies() .mapNotNull { it.currency as? CryptoCurrency.Token } .firstOrNull { token -> - token.network.backendId == networkId && + token.network.rawId == networkId && !token.isCustom && token.contractAddress.equals(contractAddress, true) } @@ -361,7 +361,7 @@ class ManageCryptoCurrenciesUseCase( launch { val assetIds = currencies.mapTo(hashSetOf()) { currency -> ExpressAsset.ID( - networkId = currency.network.backendId, + networkId = currency.network.rawId, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) } @@ -388,19 +388,19 @@ class ManageCryptoCurrenciesUseCase( ) { constructor(network: Network) : this( - networkId = network.backendId, + networkId = network.rawId, derivationPath = network.derivationPath, contractAddress = null, ) constructor(currency: CryptoCurrency) : this( - networkId = currency.network.backendId, + networkId = currency.network.rawId, derivationPath = currency.network.derivationPath, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) constructor(status: CryptoCurrencyStatus) : this( - networkId = status.currency.network.backendId, + networkId = status.currency.network.rawId, derivationPath = status.currency.network.derivationPath, contractAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress, ) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt index 24da2b65e0..9c33c3153e 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -1,6 +1,6 @@ package com.tangem.domain.account.status.utils -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrency @@ -180,7 +180,7 @@ internal object AccountCryptoCurrencyStatusFinder { contractAddress: String?, ): AccountCryptoCurrency? { return accountList.getExpectedAccounts( - rawNetworkId = networkId.rawId.value, + rawNetworkId = networkId.rawId, derivationPath = derivationPath, ) .asSequence() @@ -220,7 +220,7 @@ internal object AccountCryptoCurrencyStatusFinder { internal fun AccountStatusList.getExpectedAccountStatuses(networkId: Network.ID): List { val possibleAccountIndex = getAccountIndexOrNull( - rawNetworkId = networkId.rawId.value, + rawNetworkId = networkId.rawId, derivationPath = networkId.derivationPath, ) @@ -239,7 +239,7 @@ internal object AccountCryptoCurrencyStatusFinder { } internal fun AccountStatusList.getExpectedAccountStatuses(networks: List): List { - val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.rawId, it.derivationPath) } + val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.id.rawId, it.derivationPath) } if (possibleAccountIndexes.isEmpty()) return accountStatuses @@ -256,16 +256,14 @@ internal object AccountCryptoCurrencyStatusFinder { // region AccountList helpers internal fun AccountList.getExpectedAccounts(network: Network?): List { - return getExpectedAccounts(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath) + return getExpectedAccounts(rawNetworkId = network?.id?.rawId, derivationPath = network?.derivationPath) } private fun AccountList.getExpectedAccounts( - rawNetworkId: String?, + rawNetworkId: Network.RawID?, derivationPath: Network.DerivationPath?, ): List { - val possibleAccountIndex = getAccountIndexOrNull(rawNetworkId, derivationPath) - - return when (possibleAccountIndex) { + return when (val possibleAccountIndex = getAccountIndexOrNull(rawNetworkId, derivationPath)) { null -> accounts DerivationIndex.Main.value -> listOf(mainAccount) // currency only in the account with specific derivation index or in the main account @@ -283,10 +281,10 @@ internal object AccountCryptoCurrencyStatusFinder { // region Common helpers - private fun getAccountIndexOrNull(rawNetworkId: String?, derivationPath: Network.DerivationPath?): Int? { + private fun getAccountIndexOrNull(rawNetworkId: Network.RawID?, derivationPath: Network.DerivationPath?): Int? { if (rawNetworkId == null || derivationPath == null) return null - val blockchain = Blockchain.fromId(id = rawNetworkId) + val blockchain = rawNetworkId.toBlockchain() val recognizer = AccountNodeRecognizer(blockchain) return recognizer.recognize(derivationPath)?.toInt() diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ChooseTokenExpandedAccountsHolder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ChooseTokenExpandedAccountsHolder.kt new file mode 100644 index 0000000000..c571c27ac0 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ChooseTokenExpandedAccountsHolder.kt @@ -0,0 +1,42 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.account.repository.AccountsExpandedRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChooseTokenExpandedAccountsHolder @Inject constructor( + private val mainHolder: MainExpandedAccountsHolder, + holderFactory: DefaultExpandedAccountsHolder.Factory, + repositoryFactory: AccountsExpandedRepository.Factory, +) : ExpandedAccountsHolder { + + private val repository: AccountsExpandedRepository = + repositoryFactory.create(AccountsExpandedRepository.CHOOSE_TOKEN_FILE_NAME) + private val defaultHolder: DefaultExpandedAccountsHolder = holderFactory.create(repository) + + override fun expandedAccounts(walletId: UserWalletId): Flow> = flow { + val isStored = repository.expandedAccounts.first()[walletId] != null + + if (isStored) { + emitAll(defaultHolder.expandedAccounts(walletId)) + } else { + val initExpanded = mainHolder.expandedAccounts(walletId).first() + emitAll(defaultHolder.expandedAccounts(walletId, initExpanded)) + } + } + + override fun expandAccount(accountId: AccountId) { + defaultHolder.expandAccount(accountId) + } + + override fun collapseAccount(accountId: AccountId) { + defaultHolder.collapseAccount(accountId) + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt index eb5e9abec1..74f2ae778b 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt @@ -9,20 +9,26 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton -// todo swap separate for main and swap -@Singleton -class ExpandedAccountsHolder @Inject constructor( +interface ExpandedAccountsHolder { + fun expandedAccounts(userWallet: UserWallet): Flow> = expandedAccounts(userWallet.walletId) + fun expandedAccounts(walletId: UserWalletId): Flow> + fun expandAccount(accountId: AccountId) + fun collapseAccount(accountId: AccountId) +} + +class DefaultExpandedAccountsHolder @AssistedInject constructor( private val singleAccountListSupplier: SingleAccountListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val accountsExpandedRepository: AccountsExpandedRepository, + @Assisted private val accountsExpandedRepository: AccountsExpandedRepository, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -31,71 +37,70 @@ class ExpandedAccountsHolder @Inject constructor( onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - fun expandedAccounts(userWallet: UserWallet): Flow> = expandedAccounts(userWallet.walletId) + fun expandedAccounts(walletId: UserWalletId, initExpanded: Set = emptySet()): Flow> = + channelFlow { + val storedState = accountsExpandedRepository.expandedAccounts + .map { it[walletId] ?: initExpanded.map { id -> AccountExpandedState(id, true) } } + .stateIn(this) - fun expandedAccounts(walletId: UserWalletId): Flow> = channelFlow { - val storedState = accountsExpandedRepository.expandedAccounts - .map { it[walletId].orEmpty() } - .stateIn(this) + val isAccountsMode = isAccountsModeEnabledUseCase.invoke() + .stateIn(this) - val isAccountsMode = isAccountsModeEnabledUseCase.invoke() - .stateIn(this) + val initExpandedState = storedState.value + .mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId } + .toSet() + // main state holder + val expandedAccounts = MutableStateFlow(initExpandedState) + var debounceJob: Job? = null - val initExpandedState = storedState.value - .mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId } - .toSet() - // main state holder - val expandedAccounts = MutableStateFlow(initExpandedState) - var debounceJob: Job? = null - - actionChannel - .filter { (accountId, _) -> accountId.userWalletId == walletId } - .filter { debounceJob?.isActive != true } - .onEach { (accountId, isExpand) -> - debounceJob = launch { delay(DEBOUNCE_MILLIS) } - val newState = AccountExpandedState(accountId, isExpand) - launch { accountsExpandedRepository.update(newState) } - if (isExpand) { - expandedAccounts.update { it.plus(accountId) } - } else { - expandedAccounts.update { it.minus(accountId) } + actionChannel + .filter { (accountId, _) -> accountId.userWalletId == walletId } + .filter { debounceJob?.isActive != true } + .onEach { (accountId, isExpand) -> + debounceJob = launch { delay(DEBOUNCE_MILLIS) } + val newState = AccountExpandedState(accountId, isExpand) + launch { accountsExpandedRepository.update(newState) } + if (isExpand) { + expandedAccounts.update { it.plus(accountId) } + } else { + expandedAccounts.update { it.minus(accountId) } + } } - } - .launchIn(this) + .launchIn(this) - walletAccounts(walletId).onEach { accountList -> - if (!isAccountsModeEnabledUseCase.invokeSync()) { - accountsExpandedRepository.clearStore() - expandedAccounts.update { emptySet() } - return@onEach - } - val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId } - accountsExpandedRepository.syncStore(walletId, idsSet) - - val isSingleAccount = accountList.accounts.size == 1 - val storedMainAccountState = storedState.value - .find { it.accountId == accountList.mainAccount.accountId } - - if (isSingleAccount && storedMainAccountState == null) { - // force expand for single and not stored account - expandedAccounts.update { setOf(accountList.mainAccount.accountId) } - } - }.launchIn(this) - - combine( - flow = expandedAccounts, - flow2 = isAccountsMode, - transform = { expanded, isAccountMode -> - if (isAccountMode) { - channel.send(expanded) - } else { - channel.send(emptySet()) + walletAccounts(walletId).onEach { accountList -> + if (!isAccountsModeEnabledUseCase.invokeSync()) { + accountsExpandedRepository.clearStore() + expandedAccounts.update { emptySet() } + return@onEach } - }, - ).collect() - } - .flowOn(dispatchers.default) - .distinctUntilChanged() + val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId } + accountsExpandedRepository.syncStore(walletId, idsSet) + + val isSingleAccount = accountList.accounts.size == 1 + val storedMainAccountState = storedState.value + .find { it.accountId == accountList.mainAccount.accountId } + + if (isSingleAccount && storedMainAccountState == null) { + // force expand for single and not stored account + expandedAccounts.update { setOf(accountList.mainAccount.accountId) } + } + }.launchIn(this) + + combine( + flow = expandedAccounts, + flow2 = isAccountsMode, + transform = { expanded, isAccountMode -> + if (isAccountMode) { + channel.send(expanded) + } else { + channel.send(emptySet()) + } + }, + ).collect() + } + .flowOn(dispatchers.default) + .distinctUntilChanged() fun expandAccount(accountId: AccountId) { actionChannel.tryEmit(accountId to true) @@ -107,6 +112,11 @@ class ExpandedAccountsHolder @Inject constructor( private fun walletAccounts(walletId: UserWalletId): Flow = singleAccountListSupplier(walletId) + @AssistedFactory + interface Factory { + fun create(accountsExpandedRepository: AccountsExpandedRepository): DefaultExpandedAccountsHolder + } + companion object { private const val DEBOUNCE_MILLIS = 200L } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/MainExpandedAccountsHolder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/MainExpandedAccountsHolder.kt new file mode 100644 index 0000000000..95fba2f575 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/MainExpandedAccountsHolder.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.account.repository.AccountsExpandedRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MainExpandedAccountsHolder @Inject constructor( + holderFactory: DefaultExpandedAccountsHolder.Factory, + repositoryFactory: AccountsExpandedRepository.Factory, +) : ExpandedAccountsHolder { + + private val repository: AccountsExpandedRepository = repositoryFactory + .create(AccountsExpandedRepository.MAIN_STORE_FILE_NAME) + private val default: DefaultExpandedAccountsHolder = holderFactory.create(repository) + + override fun expandedAccounts(walletId: UserWalletId): Flow> { + return default.expandedAccounts(walletId) + } + + override fun expandAccount(accountId: AccountId) { + default.expandAccount(accountId) + } + + override fun collapseAccount(accountId: AccountId) { + default.collapseAccount(accountId) + } +} \ No newline at end of file diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt index d3257d26ed..1656279ce6 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt @@ -143,7 +143,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val defaultAddress = "0xABC" val cryptoCurrency = mockk { - every { this@mockk.network.backendId } returns token.networkId + every { this@mockk.network.rawId } returns token.networkId every { this@mockk.contractAddress } returns token.contractAddress!! } diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt index 0e42befa4f..1bbc3b9ff4 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -13,8 +13,8 @@ import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.last import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.last import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Nested @@ -81,17 +81,6 @@ class IsAccountsModeEnabledUseCaseTest { Truth.assertThat(actual).isTrue() } - @Test - fun `returns true when payment account is Locked`() = runTest { - val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) - every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) - mockPaymentStatus(WALLET_ID_1, mockk()) - - val actual = useCase.invoke().last() - - Truth.assertThat(actual).isTrue() - } - @Test fun `returns false when payment account is NotCreated`() = runTest { val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) @@ -235,17 +224,6 @@ class IsAccountsModeEnabledUseCaseTest { Truth.assertThat(actual).isTrue() } - @Test - fun `returns true when payment account is Locked`() = runTest { - val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) - coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list) - mockPaymentStatusSync(WALLET_ID_1, mockk()) - - val actual = useCase.invokeSync() - - Truth.assertThat(actual).isTrue() - } - @Test fun `returns false when payment account is NotCreated`() = runTest { val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) diff --git a/domain/tokensync/build.gradle.kts b/domain/assetsdiscovery/build.gradle.kts similarity index 73% rename from domain/tokensync/build.gradle.kts rename to domain/assetsdiscovery/build.gradle.kts index 35464995f3..6bea487412 100644 --- a/domain/tokensync/build.gradle.kts +++ b/domain/assetsdiscovery/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "com.tangem.domain.tokensync" + namespace = "com.tangem.domain.assetsdiscovery" } dependencies { @@ -14,6 +14,9 @@ dependencies { implementation(projects.domain.account.status) implementation(projects.core.utils) + implementation(projects.libs.blockchainSdk) + implementation(tangemDeps.blockchain) + implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/AssetsDiscoveryFacade.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/AssetsDiscoveryFacade.kt new file mode 100644 index 0000000000..ef873ecd3b --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/AssetsDiscoveryFacade.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.assetsdiscovery + +import com.tangem.blockchain.assetsdiscovery.AssetsDiscoveryService +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId + +interface AssetsDiscoveryFacade { + + suspend fun getAssetsDiscoveryService(userWalletId: UserWalletId, network: Network): AssetsDiscoveryServiceInfo? + + data class AssetsDiscoveryServiceInfo( + val address: String, + val service: AssetsDiscoveryService, + ) +} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/model/TokenSyncProgress.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/model/AssetsDiscoveryProgress.kt similarity index 56% rename from domain/tokensync/src/main/java/com/tangem/domain/tokensync/model/TokenSyncProgress.kt rename to domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/model/AssetsDiscoveryProgress.kt index ee78b42a70..3b3d2cb7a4 100644 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/model/TokenSyncProgress.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/model/AssetsDiscoveryProgress.kt @@ -1,13 +1,13 @@ -package com.tangem.domain.tokensync.model +package com.tangem.domain.assetsdiscovery.model -sealed class TokenSyncProgress { +sealed class AssetsDiscoveryProgress { - data object Idle : TokenSyncProgress() + data object Idle : AssetsDiscoveryProgress() data class InProgress( val completedNetworks: Int, val totalNetworks: Int, - ) : TokenSyncProgress() { + ) : AssetsDiscoveryProgress() { val progressPercent: Int get() = if (totalNetworks > 0) { completedNetworks * 100 / totalNetworks @@ -16,5 +16,5 @@ sealed class TokenSyncProgress { } } - data object Completed : TokenSyncProgress() + data object Completed : AssetsDiscoveryProgress() } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt new file mode 100644 index 0000000000..cf9020c3c1 --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.assetsdiscovery.repository + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import kotlinx.coroutines.flow.Flow + +interface AssetsDiscoveryRepository { + + suspend fun runDiscovery(userWalletId: UserWalletId) + + suspend fun completeDiscovery(userWalletId: UserWalletId) + + suspend fun getPendingDiscoveryWalletIds(): List + + fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow + + fun acknowledgeCompletion(userWalletId: UserWalletId) + + suspend fun clearPendingFlag(userWalletId: UserWalletId) + + suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List + + suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/AcknowledgeAssetsDiscoveryCompletionUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/AcknowledgeAssetsDiscoveryCompletionUseCase.kt new file mode 100644 index 0000000000..2ad99c87d0 --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/AcknowledgeAssetsDiscoveryCompletionUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.assetsdiscovery.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository + +class AcknowledgeAssetsDiscoveryCompletionUseCase( + private val assetsDiscoveryRepository: AssetsDiscoveryRepository, +) { + + operator fun invoke(userWalletId: UserWalletId) { + assetsDiscoveryRepository.acknowledgeCompletion(userWalletId) + } +} \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/ObserveAssetsDiscoveryUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/ObserveAssetsDiscoveryUseCase.kt new file mode 100644 index 0000000000..1ee927739c --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/ObserveAssetsDiscoveryUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.assetsdiscovery.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository +import kotlinx.coroutines.flow.Flow + +class ObserveAssetsDiscoveryUseCase( + private val assetsDiscoveryRepository: AssetsDiscoveryRepository, +) { + + operator fun invoke(userWalletId: UserWalletId): Flow { + return assetsDiscoveryRepository.observeDiscoveryProgress(userWalletId) + } +} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/StartTokenSyncUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt similarity index 63% rename from domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/StartTokenSyncUseCase.kt rename to domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt index d0a1efdfa5..5ba4a8ffe6 100644 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/StartTokenSyncUseCase.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt @@ -1,20 +1,23 @@ -package com.tangem.domain.tokensync.usecase +package com.tangem.domain.assetsdiscovery.usecase import arrow.core.Either +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.repository.TokenSyncRepository +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -class StartTokenSyncUseCase( - private val tokenSyncRepository: TokenSyncRepository, +class StartAssetsDiscoveryUseCase( + private val assetsDiscoveryRepository: AssetsDiscoveryRepository, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val appCoroutineScope: AppCoroutineScope, + private val analyticsEventHandler: AnalyticsEventHandler, ) { private val activeSyncJobs = ConcurrentHashMap() @@ -23,9 +26,11 @@ class StartTokenSyncUseCase( activeSyncJobs[userWalletId]?.cancel() activeSyncJobs[userWalletId] = appCoroutineScope.launch { try { - tokenSyncRepository.runSync(userWalletId) + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted()) + assetsDiscoveryRepository.runDiscovery(userWalletId) applyDiscoveredTokens(userWalletId) - tokenSyncRepository.completeSync(userWalletId) + assetsDiscoveryRepository.completeDiscovery(userWalletId) + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted()) } catch (e: Exception) { TangemLogger.e("Token sync failed for wallet: $userWalletId", e) } finally { @@ -36,18 +41,18 @@ class StartTokenSyncUseCase( suspend fun cancel(userWalletId: UserWalletId): Either = Either.catch { activeSyncJobs.remove(userWalletId)?.cancel() - tokenSyncRepository.clearPendingFlag(userWalletId) - tokenSyncRepository.clearDiscoveredTokens(userWalletId) + assetsDiscoveryRepository.clearPendingFlag(userWalletId) + assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId) } - fun applyPendingSyncs() { + fun applyPendingAssetsDiscovery() { appCoroutineScope.launch { try { - val pendingIds = tokenSyncRepository.getPendingSyncWalletIds() + val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds() for (walletId in pendingIds) { val isApplied = applyDiscoveredTokens(walletId) if (isApplied) { - tokenSyncRepository.clearPendingFlag(walletId) + assetsDiscoveryRepository.clearPendingFlag(walletId) } } } catch (e: Exception) { @@ -57,7 +62,7 @@ class StartTokenSyncUseCase( } private suspend fun applyDiscoveredTokens(userWalletId: UserWalletId): Boolean { - val currencies = tokenSyncRepository.getDiscoveredCurrencies(userWalletId) + val currencies = assetsDiscoveryRepository.getDiscoveredCurrencies(userWalletId) if (currencies.isEmpty()) return true @@ -67,7 +72,7 @@ class StartTokenSyncUseCase( add = currencies, ).fold( ifRight = { - tokenSyncRepository.clearDiscoveredTokens(userWalletId) + assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId) true }, ifLeft = { error -> diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt index 01fb3a9321..5895d2b738 100644 --- a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt @@ -33,4 +33,13 @@ sealed class TransactionParams { data class Solana( val transactions: List, ) : TransactionParams() + + /** + * Parameters for Bitcoin transactions + * + * @property params JSON-encoded transaction parameters + */ + data class Bitcoin( + val params: String, + ) : TransactionParams() } \ No newline at end of file diff --git a/domain/card/build.gradle.kts b/domain/card/build.gradle.kts index 44e17e2ce6..abd1254e93 100644 --- a/domain/card/build.gradle.kts +++ b/domain/card/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.domain.card" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { implementation(projects.core.analytics.models) implementation(projects.core.error) @@ -25,6 +29,7 @@ dependencies { implementation(projects.domain.visa.models) implementation(projects.core.utils) + implementation(projects.libs.tangemSdkApi) implementation(tangemDeps.card.core) implementation(tangemDeps.blockchain) { @@ -32,9 +37,8 @@ dependencies { } /** Testing libraries */ - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) + testRuntimeOnly(deps.test.junit5.vintage.engine) testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt index dc118e8f15..32902756ba 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt @@ -1,8 +1,33 @@ package com.tangem.domain.card import arrow.core.Either +import arrow.core.raise.either +import com.tangem.common.doOnFailure +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.logging.TangemLogger -interface DeleteSavedAccessCodesUseCase { +/** + * Removes saved user codes (access code and/or passcode) for a physical Tangem card + * from the device's secure storage. + * + * Typically invoked after a successful factory reset of the card, so that stale codes + * for an already-wiped card are not left on the device. + * + * @property tangemSdkManager Card SDK wrapper that performs the code removal operation + */ +class DeleteSavedAccessCodesUseCase( + private val tangemSdkManager: TangemSdkManager, +) { - suspend operator fun invoke(cardId: String): Either + /** + * @param cardId identifier of the card whose saved codes must be removed + * @return [Unit] on success; a Card SDK error (as [Throwable]) if removal failed + */ + suspend operator fun invoke(cardId: String): Either = either { + tangemSdkManager.deleteSavedUserCodes(cardsIds = setOf(cardId)) + .doOnFailure { error -> + TangemLogger.e("Failed to delete saved access codes for card with id: $cardId", error) + raise(error) + } + } } \ No newline at end of file diff --git a/domain/card/src/test/java/com/tangem/domain/card/DeleteSavedAccessCodesUseCaseTest.kt b/domain/card/src/test/java/com/tangem/domain/card/DeleteSavedAccessCodesUseCaseTest.kt new file mode 100644 index 0000000000..c5c0d63d9a --- /dev/null +++ b/domain/card/src/test/java/com/tangem/domain/card/DeleteSavedAccessCodesUseCaseTest.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.card + +import com.tangem.common.CompletionResult +import com.tangem.common.core.TangemSdkError +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.test.core.assertEitherLeft +import com.tangem.test.core.assertEitherRight +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DeleteSavedAccessCodesUseCaseTest { + + private val tangemSdkManager = mockk() + + private lateinit var useCase: DeleteSavedAccessCodesUseCase + + @BeforeEach + fun setup() { + clearMocks(tangemSdkManager) + useCase = DeleteSavedAccessCodesUseCase(tangemSdkManager = tangemSdkManager) + } + + @Test + fun `returns Right Unit when sdk deletes codes successfully`() = runTest { + // Arrange + val cardId = "AA00000000000001" + coEvery { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } returns CompletionResult.Success(Unit) + + // Act + val actual = useCase(cardId = cardId) + + // Assert + assertEitherRight(actual) + } + + @Test + fun `returns Left with sdk error when sdk fails`() = runTest { + // Arrange + val cardId = "AA00000000000002" + val sdkError = TangemSdkError.ExceptionError(RuntimeException("boom")) + coEvery { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } returns CompletionResult.Failure(sdkError) + + // Act + val actual = useCase(cardId = cardId) + + // Assert + assertEitherLeft(actual, sdkError) + } + + @Test + fun `passes exactly the given cardId as a singleton set to sdk`() = runTest { + // Arrange + val cardId = "AA00000000000003" + coEvery { tangemSdkManager.deleteSavedUserCodes(any()) } returns CompletionResult.Success(Unit) + + // Act + useCase(cardId = cardId) + + // Assert + coVerify(exactly = 1) { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } + } +} \ No newline at end of file diff --git a/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletSelectedHandler.kt b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletSelectedHandler.kt new file mode 100644 index 0000000000..7b5a37e76a --- /dev/null +++ b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletSelectedHandler.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.common.wallets + +import com.tangem.domain.models.wallet.UserWallet + +/** + * Handler invoked when a user wallet becomes the active one. + * + * Side effects (analytics tracking context, Tangem SDK display config, access code request policy, etc.) + * follow switch-latest semantics: if a new selection arrives while a previous one is still being processed, + * the in-flight job is cancelled and only the latest selection is applied. + */ +interface UserWalletSelectedHandler { + + suspend operator fun invoke(userWallet: UserWallet) +} \ No newline at end of file diff --git a/domain/dynamic-addresses/build.gradle.kts b/domain/dynamic-addresses/build.gradle.kts index 6190ff052f..f569b12112 100644 --- a/domain/dynamic-addresses/build.gradle.kts +++ b/domain/dynamic-addresses/build.gradle.kts @@ -8,13 +8,25 @@ android { namespace = "com.tangem.domain.dynamicaddresses" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.domain.core) api(projects.domain.dynamicAddresses.models) implementation(projects.domain.models) + implementation(projects.domain.walletManager) + implementation(projects.domain.wallets) + implementation(projects.libs.blockchainSdk) implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(tangemDeps.card.core) + + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt index 214c7ee0b7..17d8139c00 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt @@ -11,7 +11,7 @@ class DisableDynamicAddressesUseCase( /** * Returns true when consolidation is required before disabling (non-base balances exist), - * or false when DA was disabled immediately. + * or false when dynamic addresses were disabled immediately. */ suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either = Either.catch { diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationChecker.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationChecker.kt new file mode 100644 index 0000000000..f2e2bf3486 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationChecker.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.crypto.hdWallet.DerivationPath + +/** + * Shared utility for checking if a derivation path conflicts with Dynamic Addresses. + * + * A path conflicts when it belongs to the same BIP44 account as the base path + * (first 3 nodes: purpose / coin_type / account match) but has non-zero + * change (node 3) or address_index (node 4). + */ +object DynamicAddressesDerivationChecker { + + private const val BIP44_NODE_COUNT = 5 + private const val ACCOUNT_NODE_COUNT = 3 + private const val CHANGE_NODE_INDEX = 3 + private const val ADDRESS_INDEX_NODE_INDEX = 4 + + /** + * @return `true` if [path] has zero change (node 3) and zero address_index (node 4). + */ + fun isBaseDerivation(path: String): Boolean { + val nodes = runCatching { DerivationPath(path).nodes }.getOrNull() ?: return false + if (nodes.size < BIP44_NODE_COUNT) return false + + val change = nodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false) + val index = nodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false) + + return change == 0L && index == 0L + } + + /** + * @return `true` if [customPath] shares the same account as [basePath] but has + * non-zero change or address_index nodes. + */ + fun hasSameAccountWithNonZeroChangeOrIndex(customPath: String, basePath: String): Boolean { + val customNodes = runCatching { DerivationPath(customPath).nodes }.getOrNull() ?: return false + val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false + + if (customNodes.size < BIP44_NODE_COUNT || baseNodes.size < BIP44_NODE_COUNT) return false + + val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i -> + customNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false) + } + if (!isSameAccount) return false + + val change = customNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false) + val index = customNodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false) + + return change != 0L || index != 0L + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt new file mode 100644 index 0000000000..9ba156ddaa --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt @@ -0,0 +1,54 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId + +/** + * List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode). + * Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred). + * + * Dynamic addresses are NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses. + * Only the default derivation style per blockchain is supported. + */ +object DynamicAddressesSupportedBlockchains { + + private const val BIP44_PURPOSE = 44L + private const val BIP84_PURPOSE = 84L + + private val supported = setOf( + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.BitcoinCash, + Blockchain.BitcoinCashTestnet, + Blockchain.Litecoin, + Blockchain.Dogecoin, + Blockchain.Dash, + Blockchain.Ravencoin, + Blockchain.RavencoinTestnet, + ) + + private val supportedNetworkIds = supported.map { it.toNetworkId() }.toSet() + + /** + * Allowed BIP purpose nodes per network ID. + * BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH). + */ + private val allowedPurposeByNetworkId: Map = buildMap { + put(Blockchain.Bitcoin.toNetworkId(), BIP84_PURPOSE) + put(Blockchain.BitcoinTestnet.toNetworkId(), BIP84_PURPOSE) + put(Blockchain.Litecoin.toNetworkId(), BIP84_PURPOSE) + put(Blockchain.BitcoinCash.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.BitcoinCashTestnet.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.Dogecoin.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.Dash.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.Ravencoin.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.RavencoinTestnet.toNetworkId(), BIP44_PURPOSE) + } + + fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported + + fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds + + /** Returns the allowed BIP purpose node for the given network, or null if not supported */ + fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId] +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt new file mode 100644 index 0000000000..10d9872222 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.dynamicaddresses + +sealed class EnableDynamicAddressesError { + + data object ConflictingCustomTokens : EnableDynamicAddressesError() + + data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError() +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt index 8b888a54f4..7a4ceefba5 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt @@ -1,6 +1,8 @@ package com.tangem.domain.dynamicaddresses import arrow.core.Either +import arrow.core.left +import arrow.core.right import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -9,8 +11,19 @@ class EnableDynamicAddressesUseCase( private val dynamicAddressesRepository: DynamicAddressesRepository, ) { - suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either = - Either.catch { + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + xpub: String, + ): Either { + return try { + if (dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)) { + return EnableDynamicAddressesError.ConflictingCustomTokens.left() + } dynamicAddressesRepository.enable(userWalletId, network, xpub) + Unit.right() + } catch (e: Throwable) { + EnableDynamicAddressesError.ServiceError(e).left() } + } } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt new file mode 100644 index 0000000000..7d5b34d04c --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt @@ -0,0 +1,59 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.extensions.calculateRipemd160 +import com.tangem.common.extensions.calculateSha256 +import com.tangem.crypto.NetworkType +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository + +/** + * Returns the XPUB string if account-level keys are already derived (no card scan needed), + * or null if keys are not available. + */ +class GetDerivedXpubUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val derivationsRepository: DerivationsRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): String? { + val blockchain = network.toBlockchain() + if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return null + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return null + val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return null + if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return null + + val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey) + val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey) + + val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT)) + val parentPath = DerivationPath(accountPath.nodes.dropLast(1)) + + val childExtKey = existingKeys[accountPath] ?: return null + val parentExtKey = existingKeys[parentPath] ?: return null + + val parentFingerprint = parentExtKey.publicKey + .calculateSha256().calculateRipemd160() + .take(PARENT_FINGERPRINT_SIZE).toByteArray() + + val net = if (blockchain.isTestnet()) NetworkType.Testnet else NetworkType.Mainnet + return ExtendedPublicKey( + publicKey = childExtKey.publicKey, + chainCode = childExtKey.chainCode, + depth = accountPath.nodes.size, + parentFingerprint = parentFingerprint, + childNumber = accountPath.nodes.last().index, + ).serialize(net) + } + + private companion object { + const val ACCOUNT_PATH_DROP_COUNT = 2 + const val PARENT_FINGERPRINT_SIZE = 4 + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt new file mode 100644 index 0000000000..ab998d8301 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade + +/** + * Checks if XPUB generation is supported for the given wallet and network (hardware capability check). + */ +class IsXpubSupportedUseCase( + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean { + val blockchain = network.toBlockchain() + if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false + return walletManager.wallet.publicKey.derivationType?.hdKey != null + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt index c30355fa48..46f50aadd6 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt @@ -19,4 +19,18 @@ interface DynamicAddressesRepository { suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String? suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean + + /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */ + suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean + + /** Lightweight check: is the DA flag enabled for the native coin of the given network (no xpub availability check) */ + fun isDynamicAddressesEnabledForNetwork(userWalletId: UserWalletId, networkId: Network.ID): Flow + + /** + * Emits true when dynamic addresses are DISABLED for this token but a silent xpub probe + * detected non-zero balances on derived addresses beyond the base one. Only positive + * results are cached per session; false results and probe failures are not cached and may + * be re-probed on subsequent collections or when status changes. + */ + fun hasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network): Flow } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/test/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationCheckerTest.kt b/domain/dynamic-addresses/src/test/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationCheckerTest.kt new file mode 100644 index 0000000000..e89772ef81 --- /dev/null +++ b/domain/dynamic-addresses/src/test/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationCheckerTest.kt @@ -0,0 +1,203 @@ +package com.tangem.domain.dynamicaddresses + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DynamicAddressesDerivationCheckerTest { + + // region Conflicting: same account, non-zero change or index + + @Test + fun `same account, non-zero address index`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `same account, non-zero change`() { + val result = check(custom = "m/44'/5'/0'/1/0", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `same account, both change and index non-zero`() { + val result = check(custom = "m/44'/5'/0'/1/5", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `same account, large address index`() { + val result = check(custom = "m/44'/5'/0'/0/8", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `bitcoin BIP84, non-zero index`() { + val result = check(custom = "m/84'/0'/0'/0/1", base = "m/84'/0'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `litecoin BIP84, non-zero change`() { + val result = check(custom = "m/84'/2'/0'/1/0", base = "m/84'/2'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `dogecoin, non-zero index`() { + val result = check(custom = "m/44'/3'/0'/0/8", base = "m/44'/3'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `bitcoin cash, non-zero index`() { + val result = check(custom = "m/44'/145'/0'/0/3", base = "m/44'/145'/0'/0/0") + assertThat(result).isTrue() + } + + // endregion + + // region Not conflicting: different account + + @Test + fun `different account index, non-zero address index`() { + val result = check(custom = "m/44'/5'/1'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `different account index, zero change and index`() { + val result = check(custom = "m/44'/5'/1'/0/0", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `different coin type`() { + val result = check(custom = "m/44'/0'/0'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `different purpose`() { + val result = check(custom = "m/84'/5'/0'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `BIP44 custom against BIP84 base`() { + val result = check(custom = "m/44'/0'/0'/0/1", base = "m/84'/0'/0'/0/0") + assertThat(result).isFalse() + } + + // endregion + + // region Not conflicting: same account, zero change and index + + @Test + fun `identical paths`() { + val result = check(custom = "m/44'/5'/0'/0/0", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `identical bitcoin BIP84 paths`() { + val result = check(custom = "m/84'/0'/0'/0/0", base = "m/84'/0'/0'/0/0") + assertThat(result).isFalse() + } + + // endregion + + // region Edge cases: invalid or incomplete paths + + @Test + fun `invalid custom path`() { + val result = check(custom = "invalid", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `invalid base path`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "not_a_path") + assertThat(result).isFalse() + } + + @Test + fun `empty custom path`() { + val result = check(custom = "", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `empty base path`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "") + assertThat(result).isFalse() + } + + @Test + fun `both paths invalid`() { + val result = check(custom = "abc", base = "xyz") + assertThat(result).isFalse() + } + + @Test + fun `custom path with fewer than 5 nodes`() { + val result = check(custom = "m/44'/5'/0'", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `base path with fewer than 5 nodes`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'") + assertThat(result).isFalse() + } + + // endregion + + // region isBaseDerivation + + @Test + fun `isBaseDerivation - standard BIP44 base path`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/0")).isTrue() + } + + @Test + fun `isBaseDerivation - BIP84 base path`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/84'/0'/0'/0/0")).isTrue() + } + + @Test + fun `isBaseDerivation - non-zero index`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/1")).isFalse() + } + + @Test + fun `isBaseDerivation - non-zero change`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/1/0")).isFalse() + } + + @Test + fun `isBaseDerivation - non-zero account with zero change and index`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/2'/0/0")).isTrue() + } + + @Test + fun `isBaseDerivation - invalid path`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("invalid")).isFalse() + } + + @Test + fun `isBaseDerivation - too few nodes`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'")).isFalse() + } + + // endregion + + private fun check(custom: String, base: String): Boolean { + return DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex( + customPath = custom, + basePath = base, + ) + } +} \ No newline at end of file diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt index e6a93c5605..eb82e7af92 100644 --- a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt +++ b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt @@ -56,7 +56,7 @@ class GetEarnNetworksUseCase( accountLists .filter { it.userWalletId in unlockedWalletsId } .flatMap(AccountList::flattenCurrencies) - .mapTo(HashSet()) { it.network.backendId } + .mapTo(HashSet()) { it.network.rawId } } } } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt index 6d3effb0c0..ce4ddfd9dd 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt @@ -2,6 +2,7 @@ package com.tangem.domain.express.models import java.math.BigDecimal +@Suppress("MagicNumber") sealed class ExpressError : Throwable() { abstract val code: Int @@ -61,4 +62,12 @@ sealed class ExpressError : Throwable() { data object UnknownError : ExpressError() { override val code: Int = -1 } + + data class TooLargeSolanaTransactionError(override val code: Int = -2) : ExpressError() { + override val message: String = "tooLargeSolanaTransaction" + } + + data class DexActiveSupplyError(override val code: Int = -3) : ExpressError() { + override val message: String = "dexActiveSupplyError" + } } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt index 343c0bda7b..74e05c2a7c 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt @@ -23,14 +23,18 @@ enum class ExpressProviderType(val typeName: String) { ONRAMP(typeName = "ONRAMP"), ; + fun shouldStoreSwapTransaction() = when (this) { + CEX, + DEX_BRIDGE, + DEX, + -> true + ONRAMP, + -> false + } + companion object { - fun ExpressProviderType.shouldStoreSwapTransaction() = when (this) { - CEX, - DEX_BRIDGE, - -> true - DEX, - ONRAMP, - -> false + fun getSwapProviderTypes(): List { + return listOf(CEX, DEX, DEX_BRIDGE) } } } \ No newline at end of file diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt index fc76ba451b..bede31c929 100644 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt @@ -1,11 +1,12 @@ package com.tangem.domain.feedback.models +import com.tangem.domain.models.network.Network + /** * Information about blockchain's operation error * * @property errorMessage message about error - * @property blockchainId blockchain id - * @property derivationPath derivation path + * @property networkId network ID * @property destinationAddress destination address * @property tokenSymbol token symbol or null, if it isn't operation with token * @property amount amount @@ -13,8 +14,7 @@ package com.tangem.domain.feedback.models */ data class BlockchainErrorInfo( val errorMessage: String, - val blockchainId: String, - val derivationPath: String?, + val networkId: Network.ID?, val destinationAddress: String, val tokenSymbol: String?, val amount: String, diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt index 3661f00b48..0f3bb871b5 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt @@ -1,6 +1,7 @@ package com.tangem.domain.feedback.repository import com.tangem.domain.feedback.models.* +import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import java.io.File @@ -17,11 +18,7 @@ interface FeedbackRepository { fun getPhoneInfo(): PhoneInfo - suspend fun getBlockchainInfo( - userWalletId: UserWalletId, - blockchainId: String, - derivationPath: String?, - ): BlockchainInfo? + suspend fun getBlockchainInfo(userWalletId: UserWalletId, networkId: Network.ID): BlockchainInfo? fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index 0d16f8c9c2..112112773f 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -94,11 +94,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } @@ -159,11 +158,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } @@ -181,11 +179,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } @@ -210,11 +207,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } diff --git a/domain/legacy/build.gradle.kts b/domain/legacy/build.gradle.kts index 6d09dae413..59b91dd6a8 100644 --- a/domain/legacy/build.gradle.kts +++ b/domain/legacy/build.gradle.kts @@ -42,7 +42,6 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.moshi) implementation(deps.moshi.kotlin) - implementation(deps.reKotlin) ksp(deps.moshi.kotlin.codegen) /** Testing libraries */ diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt index 840894f1ad..fb5ca2619c 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt @@ -15,7 +15,6 @@ object NetworkLogConfig { object AnalyticsHandlersLogConfig { val isFirebaseLogEnabled: Boolean = BuildConfig.LOG_ENABLED - val isAmplitudeLogEnabled: Boolean = BuildConfig.LOG_ENABLED val isAppsflyerLogEnabled: Boolean = BuildConfig.LOG_ENABLED val isCustomerIoLogEnabled: Boolean = BuildConfig.LOG_ENABLED } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt index 087b4312ed..cfcb5fc1d6 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt @@ -31,11 +31,22 @@ interface RampStateManager { sendUnavailabilityReason: ScenarioUnavailabilityReason?, ): Either + /** + * Check if [CryptoCurrency] is available for swap (express/assets request) + * + * @param userWalletId the ID of the user's wallet + * @param cryptoCurrency cryptocurrency + */ suspend fun availableForSwap( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): ScenarioUnavailabilityReason + suspend fun availableForSwap( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map + suspend fun fetchSellServiceData() fun getSellInitializationStatus(): Flow> diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt deleted file mode 100644 index a9d9660b18..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.domain.redux - -import org.rekotlin.Action - -sealed interface LegacyAction : Action { - - data object PrepareDetailsScreen : LegacyAction -} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt deleted file mode 100644 index 3a96226153..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.redux - -import com.tangem.domain.models.wallet.UserWallet -import org.rekotlin.Action - -interface ReduxStateHolder { - - fun dispatch(action: Action) - - suspend fun dispatchWithMain(action: Action) - - suspend fun onUserWalletSelected(userWallet: UserWallet) -} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsInterval.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsInterval.kt new file mode 100644 index 0000000000..d87f2342c1 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsInterval.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.markets + +import kotlinx.serialization.Serializable + +@Serializable +enum class PreselectedMarketsInterval(val value: String) { + H24("24h"), + W1("1w"), + D30("30d"), + ; + + companion object { + fun parse(value: String?): PreselectedMarketsInterval? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsOrder.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsOrder.kt new file mode 100644 index 0000000000..bc2cb04640 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsOrder.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.markets + +import kotlinx.serialization.Serializable + +@Serializable +enum class PreselectedMarketsOrder(val value: String) { + Rating("rating"), + Trending("trending"), + Buyers("buyers"), + Gainers("gainers"), + Losers("losers"), + ; + + companion object { + fun parse(value: String?): PreselectedMarketsOrder? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedTokenDetailsSection.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedTokenDetailsSection.kt new file mode 100644 index 0000000000..008a3b086e --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedTokenDetailsSection.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.markets + +import kotlinx.serialization.Serializable + +@Serializable +enum class PreselectedTokenDetailsSection(val value: String) { + News("news"), + ; + + companion object { + fun parse(value: String?): PreselectedTokenDetailsSection? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/RawMarketToken.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/RawMarketToken.kt new file mode 100644 index 0000000000..26a5c74947 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/RawMarketToken.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.markets + +import com.tangem.domain.models.currency.CryptoCurrency + +/** + * minimal token info for add-to-portfolio flow + */ +data class RawMarketToken( + val id: CryptoCurrency.RawID, + val name: String, + val symbol: String, +) \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt index 4cdd14d937..46eee09ba2 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt @@ -10,7 +10,7 @@ class GetTokenMarketCryptoCurrency( ) { suspend operator fun invoke( userWalletId: UserWalletId, - tokenMarketParams: TokenMarketParams, + tokenMarketParams: RawMarketToken, network: TokenMarketInfo.Network, accountIndex: DerivationIndex, ): CryptoCurrency? { diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index acf2b29ff1..16d5ce98ea 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -42,7 +42,7 @@ interface MarketsTokenRepository { suspend fun createCryptoCurrency( userWalletId: UserWalletId, - token: TokenMarketParams, + token: RawMarketToken, network: TokenMarketInfo.Network, accountIndex: DerivationIndex? = null, ): CryptoCurrency? diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt index 467675af70..f0504c15ca 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt @@ -18,11 +18,11 @@ data class TokenReceiveConfig( @Serializable data class ReceiveAddressModel( - val nameService: NameService, + val displayType: DisplayType, val value: String, ) { - enum class NameService { - Default, Legacy, Ens + enum class DisplayType { + Default, Legacy, Ens, Dynamic, } } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt index 0c65375595..d4c73a73ef 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt @@ -37,6 +37,8 @@ data class AccountId private constructor( companion object { + const val PaymentAccountIdPrefix = "payment_" + private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") } private val hexRegex = Regex("^[a-fA-F0-9]{64}$") @@ -73,7 +75,7 @@ data class AccountId private constructor( } fun forPaymentAccount(userWalletId: UserWalletId): AccountId { - return AccountId(value = "payment_$userWalletId", userWalletId = userWalletId) + return AccountId(value = "$PaymentAccountIdPrefix$userWalletId", userWalletId = userWalletId) } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/CardDisplayName.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/CardDisplayName.kt new file mode 100644 index 0000000000..eb0bd72a9c --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/CardDisplayName.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.models.account + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import kotlinx.serialization.Serializable + +@Serializable +@ConsistentCopyVisibility +data class CardDisplayName private constructor(val value: String) { + + @Serializable + sealed interface Error { + @Serializable + data object Empty : Error + + @Serializable + data object ExceedsMaxLength : Error + + @Serializable + data object InvalidCharacters : Error + } + + companion object { + const val MAX_LENGTH = 20 + private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$") + + operator fun invoke(name: String): Either = either { + val trimmed = name.trim() + ensure(trimmed.isNotEmpty()) { Error.Empty } + ensure(trimmed.length <= MAX_LENGTH) { Error.ExceedsMaxLength } + ensure(allowedPattern.matches(trimmed)) { Error.InvalidCharacters } + CardDisplayName(trimmed) + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 058d78d061..97af48e470 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -2,9 +2,15 @@ package com.tangem.domain.models.account import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.PaymentAccountStatusValue.Loaded +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.serialization.Serializable +import java.math.BigDecimal /** * Represents the various states a payment account can have, encapsulating different information based on the state. @@ -25,7 +31,6 @@ sealed class PaymentAccountStatusValue { is UnderReview, -> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source) is Loading -> TotalFiatBalance.Loading - is Locked -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source) is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source) is Deactivated -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source) } @@ -39,7 +44,6 @@ sealed class PaymentAccountStatusValue { return when (this) { is IssuingCard -> copy(source = source) is Loaded -> copy(source = source) - is Locked -> copy(source = source) is UnderReview -> copy(source = source) is Deactivated -> copy(source = source) is Loading, @@ -102,57 +106,49 @@ sealed class PaymentAccountStatusValue { val fiatBalance: FiatBalance, ) : PaymentAccountStatusValue() - /** - * Represents a state where the payment account is locked. - * - * @property source The source of the status information. - * @property customerId The unique identifier of the customer. - * @property cardId The unique identifier of the card. - * @property lastFourDigits The last four digits of the card number. - * @property currencyCode The code of the currency. - * @property depositAddress The address for deposits, if available. - * @property isPinSet Indicates if the PIN is set for the card. - * @property fiatBalance The fiat balance details. - * @property cryptoBalance The crypto balance details. - */ - @Serializable - data class Locked( - override val source: StatusSource, - val customerId: String, - val cardId: String, - val lastFourDigits: String, - val currencyCode: String, - val depositAddress: String?, - val isPinSet: Boolean, - val fiatBalance: FiatBalance, - val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() - /** * Represents a state where the payment account is successfully loaded with complete information. * * @property source The source of the status information. * @property customerId The unique identifier of the customer. - * @property cardId The unique identifier of the card. - * @property lastFourDigits The last four digits of the card number. * @property currencyCode The code of the currency. * @property depositAddress The address for deposits, if available. - * @property isPinSet Indicates if the PIN is set for the card. * @property fiatBalance The fiat balance details. * @property cryptoBalance The crypto balance details. + * @property cards The list of user's cards. */ @Serializable data class Loaded( override val source: StatusSource, val customerId: String, - val cardId: String, - val lastFourDigits: String, val currencyCode: String, val depositAddress: String?, - val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() + val cryptoCurrency: CryptoCurrency.Token, + val cards: List, + ) : PaymentAccountStatusValue() { + val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoBalance.balance, + fiatAmount = fiatBalance.availableBalance, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = cryptoBalance.depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } /** Represents an error state for the payment account status. */ @Serializable @@ -212,4 +208,10 @@ sealed class PaymentAccountStatusValue { val tokenContractAddress: String, val balance: SerializedBigDecimal, ) -} \ No newline at end of file +} + +fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId } + +fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId } + +fun Loaded.requireCardWithId(cardId: String): TangemPayCard = requireNotNull(findCardWithId(cardId)) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt index cc408905ee..945cc04f81 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt @@ -3,7 +3,7 @@ package com.tangem.domain.models.currency import java.math.BigDecimal fun CryptoCurrency.Token.yieldSupplyKey(): String { - return "${network.backendId}_$contractAddress" + return "${network.rawId}_$contractAddress" } fun CryptoCurrencyStatus.hasNotSuppliedAmount(): Boolean { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt index b5a972d24c..028a16d2a2 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt @@ -10,7 +10,6 @@ import kotlinx.serialization.Serializable * (e.g., ERC20, BEP20). * * @property id the unique identifier of the network - * @property backendId the name of this network in the Tangem backend * @property name the human-readable name of the network, such as "Ethereum" or "Bitcoin" * @property currencySymbol the symbol of the currency associated with the network * @property derivationPath the path used to derive keys for this network @@ -25,7 +24,6 @@ import kotlinx.serialization.Serializable @Serializable data class Network( val id: ID, - val backendId: String, val name: String, val currencySymbol: String, val derivationPath: DerivationPath, @@ -49,7 +47,7 @@ data class Network( /** * Represents a unique identifier for a blockchain network * - * @property rawId raw network ID + * @property rawId raw network ID (backend id) * @property derivationPath derivation path */ @Serializable diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt new file mode 100644 index 0000000000..115e387985 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.models.pay + +import com.tangem.domain.models.account.CardDisplayName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a Tangem Pay card linked to a payment account. + * + * @property id unique card identifier assigned by the backend. + * @property hasPinCode whether the card has a PIN code set. + * @property displayName optional human-readable name assigned to the card; `null` if not set. + * @property limit spending limit configuration for the card; `null` if not configured or not yet loaded. + * @property isFrozen whether the card is currently frozen (blocked for payments). + * @property lastDigits The last four digits of the card number. + */ +@Serializable +data class TangemPayCard( + @SerialName("id") val id: String, + @SerialName("has_pin_code") val hasPinCode: Boolean, + @SerialName("display_name") val displayName: CardDisplayName?, + @SerialName("limit") val limit: TangemPayCardLimitData?, + @SerialName("is_frozen") val isFrozen: Boolean, + @SerialName("last_digits") val lastDigits: String, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimit.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimit.kt new file mode 100644 index 0000000000..01df4b7f89 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimit.kt @@ -0,0 +1,49 @@ +package com.tangem.domain.models.pay + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Locale + +@Serializable +data class TangemPayCardLimit( + @SerialName("amount") val amount: SerializedBigDecimal, + @SerialName("period") val period: TangemPayCardLimitPeriod, +) + +@Serializable +enum class TangemPayCardLimitPeriod { + @SerialName("DAY") + DAY, + + @SerialName("WEEK") + WEEK, + + @SerialName("MONTH") + MONTH, + + @SerialName("YEAR") + YEAR, + + @SerialName("ALL_TIME") + ALL_TIME, + + @SerialName("AUTHORIZATION") + AUTHORIZATION, + + @SerialName("UNKNOWN") + UNKNOWN, + ; + + companion object { + fun fromString(value: String) = when (value.uppercase(Locale.US)) { + "DAY" -> DAY + "WEEK" -> WEEK + "MONTH" -> MONTH + "YEAR" -> YEAR + "ALL_TIME" -> ALL_TIME + "AUTHORIZATION" -> AUTHORIZATION + else -> UNKNOWN + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimitData.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimitData.kt new file mode 100644 index 0000000000..bffc3a671a --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimitData.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.models.pay + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TangemPayCardLimitData( + @SerialName("actual_card_limit") val actualCardLimit: TangemPayCardLimit?, + @SerialName("admin_card_limit") val adminCardLimit: TangemPayCardLimit?, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayEligibilityType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt similarity index 89% rename from domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayEligibilityType.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt index 7f449b1bcd..2431867555 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayEligibilityType.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.models +package com.tangem.domain.models.pay enum class TangemPayEligibilityType { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayReissueCardFee.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayReissueCardFee.kt new file mode 100644 index 0000000000..26cde00590 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayReissueCardFee.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.models.pay + +import java.math.BigDecimal + +data class TangemPayReissueCardFee( + val amount: BigDecimal, + val currencyCode: String, +) \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/portfolio/UserAssetEntry.kt similarity index 69% rename from domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/portfolio/UserAssetEntry.kt index fd64a23e24..661f192008 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/portfolio/UserAssetEntry.kt @@ -1,14 +1,16 @@ -package com.tangem.domain.search.model +package com.tangem.domain.models.portfolio import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId -data class UserAssetSearchEntry( +data class UserAssetEntry( val userWalletId: UserWalletId, val userWalletName: String, val accountId: AccountId, val accountName: AccountName, + val accountIcon: CryptoPortfolioIcon, val currencyStatus: CryptoCurrencyStatus, ) \ No newline at end of file diff --git a/domain/networks/detekt-baseline-main.xml b/domain/networks/detekt-baseline-main.xml deleted file mode 100644 index eaf966bb38..0000000000 --- a/domain/networks/detekt-baseline-main.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - UnnecessaryAbstractClass:MultiNetworkStatusSupplier.kt$MultiNetworkStatusSupplier$MultiNetworkStatusSupplier - UnnecessaryAbstractClass:SingleNetworkStatusSupplier.kt$SingleNetworkStatusSupplier$SingleNetworkStatusSupplier - - diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt index 65d7623cdc..ea96369684 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt @@ -11,7 +11,7 @@ import com.tangem.domain.models.network.NetworkStatus * [REDACTED_AUTHOR] */ -abstract class MultiNetworkStatusSupplier( +open class MultiNetworkStatusSupplier( override val factory: MultiNetworkStatusProducer.Factory, override val keyCreator: (MultiNetworkStatusProducer.Params) -> String, ) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt index 8fc7770c0f..117216c91f 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt @@ -11,7 +11,7 @@ import com.tangem.domain.models.network.NetworkStatus * [REDACTED_AUTHOR] */ -abstract class SingleNetworkStatusSupplier( +open class SingleNetworkStatusSupplier( override val factory: SingleNetworkStatusProducer.Factory, override val keyCreator: (SingleNetworkStatusProducer.Params) -> String, ) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt index 67423a773a..a57cb2cd36 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt @@ -7,14 +7,12 @@ import kotlinx.serialization.Serializable * [REDACTED_AUTHOR] * - * @param language device locale (ex: en, ru). * @param snapshot id snapshot (`meta.asOf`) to stabilize responses. * @param tokenIds filter by tokens. * @param categoryIds filter by category. */ @Serializable data class NewsListConfig( - val language: String, val snapshot: String?, val tokenIds: List = emptyList(), val categoryIds: List = emptyList(), diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index d122f330f6..b1731a7431 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -38,15 +38,14 @@ interface NewsRepository { /** * Fetches and caches detailed articles for provided ids in parallel. */ - suspend fun fetchDetailedArticles(newsIds: Collection, language: String?): Either, Unit> + suspend fun fetchDetailedArticles(newsIds: Collection): Either, Unit> /** * Fetch list of trending news by limit and with correct locale and store it in runtime data store. * * @param limit - * @param language current device locale */ - suspend fun fetchTrendingNews(limit: Int, language: String?) + suspend fun fetchTrendingNews(limit: Int) /** * Observes trending news with runtime viewed flag support. diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt index fc69c6ba03..da5f344fbd 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt @@ -2,7 +2,6 @@ package com.tangem.domain.news.usecase import arrow.core.Either import com.tangem.domain.news.repository.NewsRepository -import java.util.Locale /** * Fetches trending news to store it in runtime data store. @@ -11,10 +10,7 @@ import java.util.Locale class FetchTrendingNewsUseCase(private val newsRepository: NewsRepository) { suspend operator fun invoke(): Either = Either.catch { - newsRepository.fetchTrendingNews( - limit = LIMIT_FOR_TRENDING_NEWS, - language = Locale.getDefault().language, - ) + newsRepository.fetchTrendingNews(limit = LIMIT_FOR_TRENDING_NEWS) } companion object { diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt index 06d9678f90..e3a8d39bf4 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt @@ -24,6 +24,6 @@ class ObserveNewsDetailsUseCase( /** * Prefetches the given article ids (can be called with current + next ids for pager preloading). */ - suspend fun prefetch(newsIds: Collection, language: String?): Either, Unit> = - repository.fetchDetailedArticles(newsIds, language) + suspend fun prefetch(newsIds: Collection): Either, Unit> = + repository.fetchDetailedArticles(newsIds) } \ No newline at end of file diff --git a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt index 9cc06cdb94..b351f18f9f 100644 --- a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt +++ b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt @@ -24,5 +24,5 @@ data class StoryContent( } enum class StoryContentIds(val id: String, val analyticType: String) { - STORY_FIRST_TIME_SWAP(id = "first-time-swap", analyticType = "Swap"), + STORY_FIRST_TIME_SWAP(id = "first-time-swap-v2", analyticType = "Swap"), } \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt b/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt index 98a25e4996..40fc76fed4 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt @@ -3,5 +3,5 @@ package com.tangem.domain.search.model data class SearchResult( val textHints: List, val recentTokens: List, - val userAssets: List, + val userAssets: List, ) \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt new file mode 100644 index 0000000000..86387af79a --- /dev/null +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.search.model + +import com.tangem.domain.models.portfolio.UserAssetEntry + +sealed interface UserAssetSearchItem { + + data class Single(val entry: UserAssetEntry) : UserAssetSearchItem + + data class Grouped( + val tokenName: String, + val tokenSymbol: String, + val tokenIconUrl: String?, + val entries: List, + ) : UserAssetSearchItem +} \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt index b6167208a2..ba5da04383 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt @@ -8,11 +8,13 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.search.model.SearchResult -import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.repository.SearchRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +import java.math.BigDecimal /** * Primary search use case that produces [SearchResult] based on the current query. @@ -70,9 +72,12 @@ class GetSearchResultsUseCase( if (unlockedWallets.isEmpty()) return@combine emptyList() - statusLists + val entries = statusLists .filter { it.userWalletId in unlockedWallets } .flatMap { statusList -> extractMatchingAssets(statusList, unlockedWallets, lowerQuery) } + + val shouldGroup = needsGrouping(unlockedWallets.values, statusLists) + groupAndSort(entries, shouldGroup) }.map { userAssets -> SearchResult( textHints = emptyList(), @@ -82,11 +87,49 @@ class GetSearchResultsUseCase( } } + private fun needsGrouping(unlockedWallets: Collection, statusLists: List): Boolean { + if (unlockedWallets.size > 1) return true + + val totalAccounts = statusLists + .filter { sl -> unlockedWallets.any { it.walletId == sl.userWalletId } } + .sumOf { it.accountStatuses.filterCryptoPortfolio().size } + + return totalAccounts > 1 + } + + private fun groupAndSort(entries: List, shouldGroup: Boolean): List { + if (!shouldGroup) { + return entries + .map { UserAssetSearchItem.Single(it) } + .sortedByDescending { it.entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + } + + val grouped = entries.groupBy { entry -> + val rawId = entry.currencyStatus.currency.id.rawCurrencyId + rawId?.value ?: "${entry.currencyStatus.currency.name}|${entry.currencyStatus.currency.symbol}" + } + + return grouped.map { (_, groupEntries) -> + val assetInfo = groupEntries.first() + UserAssetSearchItem.Grouped( + tokenName = assetInfo.currencyStatus.currency.name, + tokenSymbol = assetInfo.currencyStatus.currency.symbol, + tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl, + entries = groupEntries, + ) + }.sortedByDescending { item -> + when (item) { + is UserAssetSearchItem.Grouped -> + item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + } + } + } + private fun extractMatchingAssets( statusList: AccountStatusList, wallets: Map, lowerQuery: String, - ): List { + ): List { val wallet = wallets[statusList.userWalletId] ?: return emptyList() return statusList.accountStatuses .filterCryptoPortfolio() @@ -98,11 +141,12 @@ class GetSearchResultsUseCase( name.contains(lowerQuery) || symbol.contains(lowerQuery) } .map { currencyStatus -> - UserAssetSearchEntry( + UserAssetEntry( userWalletId = statusList.userWalletId, userWalletName = wallet.name, accountId = accountStatus.accountId, accountName = accountStatus.account.accountName, + accountIcon = accountStatus.account.icon, currencyStatus = currencyStatus, ) } diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/HotWalletRestrictionManager.kt b/domain/settings/src/main/java/com/tangem/domain/settings/HotWalletRestrictionManager.kt new file mode 100644 index 0000000000..dfae240b0f --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/HotWalletRestrictionManager.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.settings + +import kotlinx.coroutines.flow.StateFlow + +/** + * Manages the hot wallet creation restriction setting. + * + * When the restriction is enabled, users are forced to scan a physical Tangem card + * instead of being able to create a new software (hot) wallet. + */ +interface HotWalletRestrictionManager { + + /** Observes the current restriction state as a [StateFlow]. */ + fun isCreationEnabled(): StateFlow + + /** Returns the latest cached restriction state synchronously. */ + fun isCreationEnabledSync(): Boolean + + /** Toggles the restriction state. No-op in production. */ + suspend fun toggleCreationEnabled() +} \ No newline at end of file diff --git a/domain/staking/detekt-baseline-debug.xml b/domain/staking/detekt-baseline-debug.xml deleted file mode 100644 index f52428665c..0000000000 --- a/domain/staking/detekt-baseline-debug.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } - MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending && action.amount < it.amount && it.type == BalanceType.STAKED && it.validatorAddress == action.validatorAddress } - NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId) - UnnecessaryAbstractClass:MultiStakingBalanceSupplier.kt$MultiStakingBalanceSupplier$MultiStakingBalanceSupplier - UnnecessaryAbstractClass:SingleStakingBalanceSupplier.kt$SingleStakingBalanceSupplier$SingleStakingBalanceSupplier - UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf() - UseOrEmpty:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase$action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "" - - diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt index a438d88cef..2e4836733e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt @@ -23,9 +23,9 @@ class FetchStakingYieldBalanceUseCase( currencyId = cryptoCurrency.id, network = cryptoCurrency.network, ) - .getOrElse { - when (it) { - is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) + .getOrElse { error -> + when (error) { + is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$error")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt index 3402b6bb03..d9f06df359 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt @@ -4,10 +4,11 @@ import arrow.core.Either import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.network.Network import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction -import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakeKitRepository +import com.tangem.domain.staking.repositories.StakingErrorResolver class GetConstructedStakingTransactionUseCase( private val stakeKitRepository: StakeKitRepository, @@ -15,12 +16,17 @@ class GetConstructedStakingTransactionUseCase( ) { suspend operator fun invoke( - networkId: String, + networkId: Network.RawID, fee: Fee, amount: Amount, transactionId: String, ): Either> = Either.catch { - stakeKitRepository.constructTransaction(networkId, fee, amount, transactionId) + stakeKitRepository.constructTransaction( + networkId = networkId, + fee = fee, + amount = amount, + transactionId = transactionId, + ) }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt index f24cac1f74..a58ad4c346 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt @@ -100,7 +100,7 @@ class InvalidatePendingTransactionsUseCase( type = BalanceType.STAKED, amount = action.amount, rawCurrencyId = null, - validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "", + validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0).orEmpty(), date = null, pendingActions = emptyList(), pendingActionsConstraints = emptyList(), @@ -149,10 +149,10 @@ class InvalidatePendingTransactionsUseCase( } private fun findPartialUnstake(balances: MutableList, action: StakingAction): Pair { - val index = balances.indexOfFirst { - !it.isPending && action.amount < it.amount && - it.type == BalanceType.STAKED && - it.validatorAddress == action.validatorAddress + val index = balances.indexOfFirst { balance -> + !balance.isPending && action.amount < balance.amount && + balance.type == BalanceType.STAKED && + balance.validatorAddress == action.validatorAddress } return index to action.amount } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt index c8648ad64c..6fb6297f3f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt @@ -2,23 +2,27 @@ package com.tangem.domain.staking import arrow.core.Either import arrow.core.raise.either +import arrow.core.raise.ensure import arrow.core.raise.ensureNotNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.staking.StakingID import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade /** * Factory class for creating instances of [StakingID] * - * @property walletManagersFacade wallet manager facade + * @property walletManagersFacade wallet manager facade + * @property stakingFeatureToggles staking feature toggles * [REDACTED_AUTHOR] */ class StakingIdFactory( private val walletManagersFacade: WalletManagersFacade, + private val stakingFeatureToggles: StakingFeatureToggles, ) { /** @@ -72,6 +76,8 @@ class StakingIdFactory( ensureNotNull(integrationId) { Error.UnsupportedCurrency } + ensure(stakingFeatureToggles.isIntegrationEnabled(integrationId)) { Error.UnsupportedCurrency } + val address = defaultAddressProvider().takeUnless { it.isNullOrEmpty() } ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index fbe3c49e85..8b4e35aa0d 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.staking.action.StakingActionType sealed class StakingAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent( category = "Staking", event = event, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt index db33de7982..0705a3a520 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt @@ -147,7 +147,7 @@ sealed interface StakingIntegrationID { * @return a [StakingIntegrationID] if supported, or `null` if not supported. */ fun create(currencyId: CryptoCurrency.ID): StakingIntegrationID? { - val blockchain = Blockchain.fromId(id = currencyId.rawNetworkId) + val blockchain = currencyId.toBlockchain() return if (currencyId.contractAddress.isNullOrBlank()) { // Order is not important — either P2PEthPool or Stakekit.Coin can be in any order diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt index 106e390f01..7932714729 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance * [REDACTED_AUTHOR] */ -abstract class MultiStakingBalanceSupplier( +open class MultiStakingBalanceSupplier( override val factory: FlowProducer.Factory, override val keyCreator: (MultiStakingBalanceProducer.Params) -> String, ) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt index e614a685bc..876e026c8b 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt @@ -5,11 +5,11 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.staking.model.StakingIntegrationID -import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus @@ -55,7 +55,7 @@ interface StakeKitRepository { suspend fun estimateGas(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingGasEstimate suspend fun constructTransaction( - networkId: String, + networkId: Network.RawID, fee: Fee, amount: Amount, transactionId: String, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt index 9474e0e172..a9050acd21 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance * [REDACTED_AUTHOR] */ -abstract class SingleStakingBalanceSupplier( +open class SingleStakingBalanceSupplier( override val factory: FlowProducer.Factory, override val keyCreator: (SingleStakingBalanceProducer.Params) -> String, ) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt index 3553692065..80761562fc 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt @@ -1,5 +1,8 @@ package com.tangem.domain.staking.toggles +import com.tangem.domain.staking.model.StakingIntegrationID + interface StakingFeatureToggles { - val isEthStakingEnabled: Boolean + + fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt index 71a5d271ed..1d754890f5 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt @@ -5,17 +5,16 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.test.core.ProvideTestModels -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -30,11 +29,16 @@ import org.junit.jupiter.params.ParameterizedTest internal class StakingIdFactoryTest { private val walletManagersFacade: WalletManagersFacade = mockk() - private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade) + private val stakingFeatureToggles: StakingFeatureToggles = mockk() + private val factory = StakingIdFactory( + walletManagersFacade = walletManagersFacade, + stakingFeatureToggles = stakingFeatureToggles, + ) @BeforeEach fun resetMocks() { - clearMocks(walletManagersFacade) + clearMocks(walletManagersFacade, stakingFeatureToggles) + every { stakingFeatureToggles.isIntegrationEnabled(any()) } returns true } @Nested @@ -66,6 +70,33 @@ internal class StakingIdFactoryTest { } } + @Test + fun `create returns UnsupportedCurrency if integration is disabled by toggle`() = runTest { + // Arrange + val userWalletId = UserWalletId(stringValue = "011") + val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON) + + every { + stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.StakeKit.Coin.Ton) + } returns false + + // Act + val actual = factory.create( + userWalletId = userWalletId, + currencyId = currency.id, + network = currency.network, + ) + + // Assert + val expected = StakingIdFactory.Error.UnsupportedCurrency + + Truth.assertThat(actual.leftOrNull()).isEqualTo(expected) + + coVerify(inverse = true) { + walletManagersFacade.getDefaultAddress(userWalletId = any(), network = any()) + } + } + @Test fun `create returns UnableToGetAddress if address is null`() = runTest { // Arrange @@ -154,7 +185,7 @@ internal class StakingIdFactoryTest { ), CreateModel( currencyId = CryptoCurrency.ID.fromValue( - value = "token⟨ETH⟩polygon-ecosystem-token⚓0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", + value = "token⟨ethereum⟩polygon-ecosystem-token⚓0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", ), expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.EthereumToken.Polygon), ), @@ -168,6 +199,6 @@ internal class StakingIdFactoryTest { data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: Either) private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID { - return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}⟩") + return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.toNetworkId()}⟩") } } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt index c62e943341..c35f5af029 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt @@ -2,6 +2,7 @@ package com.tangem.domain.staking import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.StakingIntegrationID @@ -144,11 +145,11 @@ class StakingIntegrationIDTest { expected = StakingIntegrationID.P2PEthPool, ), CreateModel( - currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩polygon-ecosystem-token⚓1234567890"), + currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ethereum⟩polygon-ecosystem-token⚓1234567890"), expected = StakingIntegrationID.StakeKit.EthereumToken.Polygon, ), CreateModel( - currencyId = CryptoCurrency.ID.fromValue(value = "token⟨SOLANA⟩solana⚓1234567890"), + currencyId = CryptoCurrency.ID.fromValue(value = "token⟨solana⟩solana⚓1234567890"), expected = null, ), ) @@ -157,6 +158,6 @@ class StakingIntegrationIDTest { data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: StakingIntegrationID?) private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID { - return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}⟩") + return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.toNetworkId()}⟩") } } \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt new file mode 100644 index 0000000000..4a0067251f --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.swap.models + +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Represents the status of a cryptocurrency in the context of a swap operation. + * + * Combines the [UserWallet], [CryptoCurrencyStatus], and [Account] to provide + * all necessary information about a currency participating in a swap. + * + * @property userWallet the user wallet that owns the currency + * @property status the current status of the cryptocurrency, including balance and value state + * @property account the account within the wallet that holds the currency + * @property currency shortcut to the [CryptoCurrency] from [status] + * @property userWalletId shortcut to the wallet ID from [userWallet] + * @property isAvailableForSwap whether this currency can participate in a swap operation, + * determined by [RampStateManager][com.tangem.domain.exchange.RampStateManager] + */ +data class SwapCurrencyStatus( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val account: Account, + val isAvailableForSwap: Boolean = true, +) { + val currency: CryptoCurrency + get() = status.currency + val userWalletId: UserWalletId + get() = userWallet.walletId +} \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt index 5c8e52d327..cbaf128a59 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt @@ -9,7 +9,8 @@ import java.math.BigDecimal * List of saved swap transactions */ data class SwapTransactionListModel( - val userWalletId: String, + val fromUserWalletId: String, + val toUserWalletId: String, val fromCryptoCurrencyId: String, val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt index dacec2c058..f852d6c8cc 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt @@ -16,6 +16,21 @@ import java.math.BigDecimal @Suppress("LongParameterList") interface SwapRepositoryV2 { + /** + * Returns express swap pairs for a specific primary and secondary currency. + * + * @param primarySwapCurrencyStatus primary currency status participating in the swap + * @param secondarySwapCurrencyStatus secondary currency status participating in the swap + * @param filterProviderTypes filters only specified provider types, if empty returns providers as is + * @param swapTxType swap tx type + */ + suspend fun getPairs( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ): List + /** * Express swap pairs, both direct and reversed * @@ -84,7 +99,7 @@ interface SwapRepositoryV2 { userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toAddress: String, toExtraId: String?, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt index 3e22c172a7..4b450ef0b8 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt @@ -18,7 +18,8 @@ interface SwapTransactionRepository { /** * Store new swap transaction * - * @param userWalletId selected user wallet id + * @param fromUserWalletId wallet id swap from + * @param toUserWalletId wallet id swap to * @param fromCryptoCurrency currency swap from * @param toCryptoCurrency currency swap to * @param fromAccount account swap from @@ -27,7 +28,8 @@ interface SwapTransactionRepository { */ @Suppress("LongParameterList") suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt index 2726e77e94..616afe33e8 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt @@ -12,6 +12,7 @@ import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDataModel +import java.math.BigDecimal @Suppress("LongParameterList") class GetSwapDataUseCase( @@ -22,7 +23,7 @@ class GetSwapDataUseCase( suspend operator fun invoke( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toCryptoCurrency: CryptoCurrency, toAddress: String, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt new file mode 100644 index 0000000000..101edf46e2 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.swap.usecase + +import arrow.core.Either +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.swap.SwapErrorResolver +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapTxType + +/** + * Use case for retrieving swap pairs between a specific primary and secondary currency. + * + * Returns either a list of available swap pairs or a resolved swap error. + * + * @property swapRepositoryV2 repository providing swap pair data + * @property swapErrorResolver resolver that maps exceptions to domain swap errors + */ +class GetSwapPairUseCase( + private val swapRepositoryV2: SwapRepositoryV2, + private val swapErrorResolver: SwapErrorResolver, +) { + suspend operator fun invoke( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ) = Either.catch { + swapRepositoryV2.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = filterProviderTypes, + swapTxType = swapTxType, + ) + }.mapLeft(swapErrorResolver::resolve) +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt index 70c9811d57..b7ed209339 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt @@ -2,7 +2,6 @@ package com.tangem.domain.swap.usecase import arrow.core.Either import com.tangem.domain.express.models.ExpressProvider -import com.tangem.domain.express.models.ExpressProviderType.Companion.shouldStoreSwapTransaction import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -19,7 +18,8 @@ class SwapTransactionSentUseCase( ) { suspend operator fun invoke( - userWallet: UserWallet, + fromUserWallet: UserWallet, + toUserWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrencyStatus: CryptoCurrencyStatus, fromAccount: Account?, @@ -33,7 +33,8 @@ class SwapTransactionSentUseCase( ) = Either.catch { if (provider.type.shouldStoreSwapTransaction()) { swapTransactionRepository.storeTransaction( - userWalletId = userWallet.walletId, + fromUserWalletId = fromUserWallet.walletId, + toUserWalletId = toUserWallet.walletId, fromCryptoCurrency = fromCryptoCurrencyStatus.currency, toCryptoCurrency = toCryptoCurrencyStatus.currency, fromAccount = fromAccount, @@ -58,11 +59,11 @@ class SwapTransactionSentUseCase( } swapTransactionRepository.storeLastSwappedCryptoCurrencyId( - userWalletId = userWallet.walletId, + userWalletId = fromUserWallet.walletId, cryptoCurrencyId = toCryptoCurrencyStatus.currency.id, ) swapRepositoryV2.swapTransactionSent( - userWallet = userWallet, + userWallet = fromUserWallet, fromCryptoCurrencyStatus = fromCryptoCurrencyStatus, payInAddress = payInAddress, txId = swapDataTransactionModel.txId, diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 236ce7785f..5fc40e8cea 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -56,7 +56,6 @@ dependencies { /** Utils */ implementation(deps.jodatime) - implementation(deps.reKotlin) implementation(tangemDeps.blockchain) { exclude(module = "joda-time") diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt index bd66565b40..0b817b3324 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt @@ -6,6 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.ENS import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM +import com.tangem.core.analytics.models.AppsFlyerIncludedEvent sealed class TokenReceiveNewAnalyticsEvent( event: String, @@ -36,7 +37,7 @@ sealed class TokenReceiveNewAnalyticsEvent( BLOCKCHAIN to blockchainName, SOURCE to tokenReceiveSource.name, ), - ) + ), AppsFlyerIncludedEvent class ButtonCopyEns( token: String, diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt new file mode 100644 index 0000000000..9eb298cf27 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.tokens.model.warnings + +sealed class DynamicAddressesWarnings : CryptoCurrencyWarning() { + + data object FundsFound : DynamicAddressesWarnings() +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt index 9538efb817..2071908140 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt @@ -116,7 +116,10 @@ class BalanceFetchingOperations( val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = currency) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { - TangemLogger.e("Unable to get staking ID for user wallet $userWalletId and currency ${currency.id}") + TangemLogger.e( + messageString = "Unable to get stakingID for user wallet $userWalletId and currency ${currency.id}", + shouldSanitize = false, + ) } stakingId.getOrNull() diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index a5ee00426b..73d73a599f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -3,14 +3,11 @@ package com.tangem.domain.tokens.actions import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState.ActionState -import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.models.YieldSupplyAvailability -import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -65,20 +62,6 @@ internal class CommonActionsFactory( getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus) } - val swapUnavailabilityReason = if (!cryptoCurrencyStatus.currency.isCustom && - cryptoCurrencyStatus.value !is CryptoCurrencyStatus.NoQuote - ) { - async { - getSwapUnavailabilityReason( - userWalletId = userWallet.walletId, - currencyStatus = cryptoCurrencyStatus, - requirementsDeferred = requirementsDeferred, - ) - } - } else { - null - } - val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet) actionAvailabilityBuilder { @@ -111,7 +94,6 @@ internal class CommonActionsFactory( createSwapAction( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, - swapUnavailableReasonDeferred = swapUnavailabilityReason, shouldShowSwapStories = shouldShowSwapStories, ).addByReason() // endregion @@ -140,11 +122,9 @@ internal class CommonActionsFactory( } } - @Suppress("CanBeNonNullable") - private suspend fun createSwapAction( + private fun createSwapAction( userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus, - swapUnavailableReasonDeferred: Deferred?, shouldShowSwapStories: Boolean, ): ActionState { val cryptoCurrency = cryptoCurrencyStatus.currency @@ -172,35 +152,11 @@ internal class CommonActionsFactory( ) } else -> { - val reason = requireNotNull(swapUnavailableReasonDeferred) { - "swapUnavailableReasonDeferred must not be null for available swap action" - }.await() - - return ActionState.Swap( - unavailabilityReason = reason, - shouldShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories, + ActionState.Swap( + unavailabilityReason = ScenarioUnavailabilityReason.None, + shouldShowBadge = shouldShowSwapStories, ) } } } - - private suspend fun getSwapUnavailabilityReason( - userWalletId: UserWalletId, - currencyStatus: CryptoCurrencyStatus, - requirementsDeferred: Deferred?, - ): ScenarioUnavailabilityReason { - val swapUnavailabilityReason = rampStateManager - .availableForSwap(userWalletId = userWalletId, cryptoCurrency = currencyStatus.currency) - val shouldCheckAssetRequirements = - swapUnavailabilityReason == ScenarioUnavailabilityReason.None && requirementsDeferred != null - - val yieldSupplyStatus = currencyStatus.value.yieldSupplyStatus - val isUnavailableByYieldSupply = yieldSupplyStatus?.isAllowedToSpend == false && yieldSupplyStatus.isActive - - return when { - isUnavailableByYieldSupply -> ScenarioUnavailabilityReason.YieldSupplyApprovalRequired - shouldCheckAssetRequirements -> getReceiveScenario(requirementsDeferred.await()) - else -> swapUnavailabilityReason - } - } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt index 7d6d8f4051..8f43d68f33 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt @@ -1,7 +1,6 @@ package com.tangem.domain.tokens.wallet import arrow.core.Either -import arrow.core.right import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -147,18 +146,10 @@ class WalletBalanceFetcher internal constructor( fetchExpressAssets(userWallet = userWallet, currencies = currencies) - fetcher.fetch( - userWalletId = userWalletId, - currencies = currencies, - paymentAccountRefactorEnabled = params.isPaymentAccountRefactorEnabled, - ) + fetcher.fetch(userWalletId = userWalletId, currencies = currencies) } - private suspend fun BaseWalletBalanceFetcher.fetch( - userWalletId: UserWalletId, - currencies: Set, - paymentAccountRefactorEnabled: Boolean, - ) { + private suspend fun BaseWalletBalanceFetcher.fetch(userWalletId: UserWalletId, currencies: Set) { coroutineScope { // Fetch balance sources in parallel val balanceErrors = fetchingSources.filterIsInstance() @@ -182,7 +173,7 @@ class WalletBalanceFetcher internal constructor( // Fetch TangemPay separately — may run long-polling, so it must not block balance error checking if (fetchingSources.any { it is WalletFetchingSource.TangemPay }) { - fetchPaymentAccount(userWalletId, paymentAccountRefactorEnabled) + paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } } } @@ -190,26 +181,17 @@ class WalletBalanceFetcher internal constructor( private suspend fun fetchExpressAssets(userWallet: UserWallet, currencies: Set) { val assetIds = currencies.mapTo(hashSetOf()) { currency -> ExpressAsset.ID( - networkId = currency.network.backendId, + networkId = currency.network.rawId, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) } expressServiceFetcher.fetch(userWallet = userWallet, assetIds = assetIds) } - private suspend fun fetchPaymentAccount( - userWalletId: UserWalletId, - paymentAccountRefactorEnabled: Boolean, - ): Either { - if (!paymentAccountRefactorEnabled) return Unit.right() - - return paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) - } - /** * Params of [WalletBalanceFetcher] * * @property userWalletId user wallet id */ - data class Params(val userWalletId: UserWalletId, val isPaymentAccountRefactorEnabled: Boolean) + data class Params(val userWalletId: UserWalletId) } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt index d856cd5af9..6a98966d0d 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt @@ -18,7 +18,6 @@ internal object MockNetworks { name = "Network One", isTestnet = false, standardType = Network.StandardType.ERC20, - backendId = "network1", currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, @@ -32,7 +31,6 @@ internal object MockNetworks { name = "Network Two", isTestnet = false, standardType = Network.StandardType.ERC20, - backendId = "network1", currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, @@ -46,7 +44,6 @@ internal object MockNetworks { name = "Network Three", isTestnet = false, standardType = Network.StandardType.ERC20, - backendId = "network1", currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt index c3ab25891d..a345573819 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt @@ -96,7 +96,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -133,7 +132,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -171,7 +169,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -209,7 +206,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -260,7 +256,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -315,7 +310,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -376,7 +370,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -428,7 +421,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -479,7 +471,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -536,7 +527,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -607,7 +597,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -682,7 +671,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -743,7 +731,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -802,7 +789,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -869,7 +855,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/repository/TokenSyncRepository.kt b/domain/tokensync/src/main/java/com/tangem/domain/tokensync/repository/TokenSyncRepository.kt deleted file mode 100644 index b56da08053..0000000000 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/repository/TokenSyncRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.domain.tokensync.repository - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.model.TokenSyncProgress -import kotlinx.coroutines.flow.Flow - -interface TokenSyncRepository { - - suspend fun runSync(userWalletId: UserWalletId) - - suspend fun completeSync(userWalletId: UserWalletId) - - suspend fun getPendingSyncWalletIds(): List - - fun observeSyncProgress(userWalletId: UserWalletId): Flow - - fun acknowledgeCompletion(userWalletId: UserWalletId) - - suspend fun clearPendingFlag(userWalletId: UserWalletId) - - suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List - - suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) -} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/AcknowledgeTokenSyncCompletionUseCase.kt b/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/AcknowledgeTokenSyncCompletionUseCase.kt deleted file mode 100644 index 35be72c523..0000000000 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/AcknowledgeTokenSyncCompletionUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.tokensync.usecase - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.repository.TokenSyncRepository - -class AcknowledgeTokenSyncCompletionUseCase( - private val tokenSyncRepository: TokenSyncRepository, -) { - - operator fun invoke(userWalletId: UserWalletId) { - tokenSyncRepository.acknowledgeCompletion(userWalletId) - } -} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/ObserveTokenSyncUseCase.kt b/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/ObserveTokenSyncUseCase.kt deleted file mode 100644 index cddfbb9d7f..0000000000 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/ObserveTokenSyncUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.domain.tokensync.usecase - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import kotlinx.coroutines.flow.Flow - -class ObserveTokenSyncUseCase( - private val tokenSyncRepository: TokenSyncRepository, -) { - - operator fun invoke(userWalletId: UserWalletId): Flow { - return tokenSyncRepository.observeSyncProgress(userWalletId) - } -} \ No newline at end of file diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index aae9c5368c..cc13ce06eb 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -27,6 +27,8 @@ dependencies { implementation(projects.libs.crypto) implementation(projects.domain.account.status) + implementation(projects.domain.dynamicAddresses) + implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.models) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt index 1aec1f720e..ebb7a3bf0e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt @@ -40,8 +40,8 @@ class GetEthSpecificFeeUseCase( ?: (walletManager as? EthereumWalletManager)?.getGasPriceValue() ?: error("not supported for ${cryptoCurrency.network}") - val blockchain = Blockchain.fromNetworkId(networkId = cryptoCurrency.network.backendId) - ?: error("unknown networkId ${cryptoCurrency.network.backendId}") + val blockchain = Blockchain.fromNetworkId(networkId = cryptoCurrency.network.rawId) + ?: error("unknown networkId ${cryptoCurrency.network.rawId}") val minimalFee = getEthLegacyFee( gasPrice = gasPriceResult, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt index e0b596f81e..93a25ab440 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt @@ -1,5 +1,9 @@ package com.tangem.domain.transaction.usecase +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.Asset import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig @@ -12,10 +16,15 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.transaction.R import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.firstOrNull class ReceiveAddressesFactory( private val getEnsNameUseCase: GetEnsNameUseCase, private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + private val getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) { suspend fun create( @@ -32,27 +41,14 @@ class ReceiveAddressesFactory( address = addresses.defaultAddress.value, ) - val receiveAddresses = buildList { - ensName?.let { ens -> - add( - ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, - value = ens, - ), - ) - } - addresses.availableAddresses.map { address -> - add( - ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy - }, - value = address.value, - ), - ) - } + val dynamicAddress = getDynamicAddressIfEnabled(userWalletId, cryptoCurrency) + + val receiveAddresses = if (dynamicAddress != null) { + buildDynamicAddressList(ensName, dynamicAddress) + } else { + buildStandardAddressList(ensName, addresses) } + return TokenReceiveConfig( shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(), cryptoCurrency = cryptoCurrency, @@ -64,6 +60,52 @@ class ReceiveAddressesFactory( ) } + private suspend fun getDynamicAddressIfEnabled( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): String? { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return null + if (cryptoCurrency !is CryptoCurrency.Coin) return null + + val status = dynamicAddressesRepository.getStatus(userWalletId, cryptoCurrency.network).firstOrNull() + if (status != DynamicAddressesStatus.ENABLED) return null + + return getDynamicReceiveAddressUseCase(userWalletId, cryptoCurrency.network) + .onLeft { TangemLogger.e("Failed to get dynamic receive address: ${it.message}") } + .getOrNull() + } + + private fun buildDynamicAddressList(ensName: String?, dynamicAddress: String): List = + buildList { + ensName?.let { ens -> + add(ReceiveAddressModel(displayType = ReceiveAddressModel.DisplayType.Ens, value = ens)) + } + add( + ReceiveAddressModel( + displayType = ReceiveAddressModel.DisplayType.Dynamic, + value = dynamicAddress, + ), + ) + } + + private fun buildStandardAddressList(ensName: String?, addresses: NetworkAddress): List = + buildList { + ensName?.let { ens -> + add(ReceiveAddressModel(displayType = ReceiveAddressModel.DisplayType.Ens, value = ens)) + } + addresses.availableAddresses.map { address -> + add( + ReceiveAddressModel( + displayType = when (address.type) { + NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.DisplayType.Default + NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.DisplayType.Legacy + }, + value = address.value, + ), + ) + } + } + suspend fun createForNft( userWalletId: UserWalletId, addresses: NetworkAddress, @@ -82,7 +124,7 @@ class ReceiveAddressesFactory( ensName?.let { ens -> add( ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, + displayType = ReceiveAddressModel.DisplayType.Ens, value = ens, ), ) @@ -90,9 +132,9 @@ class ReceiveAddressesFactory( addresses.availableAddresses.map { address -> add( ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy + displayType = when (address.type) { + NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.DisplayType.Default + NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.DisplayType.Legacy }, value = address.value, ), diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt index 2906021583..f94eb30195 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt @@ -1,5 +1,6 @@ package com.tangem.domain.pay +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.serialization.Serializable @@ -12,4 +13,5 @@ data class TangemPayDetailsConfig( val cardNumberEnd: String, val chainId: Int, val isTangemPayDeactivated: Boolean, + val displayName: CardDisplayName?, ) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt index 1004e00447..31a2537914 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt @@ -5,8 +5,8 @@ import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +@Deprecated("TangemPayCurrencyFactory") interface TangemPayCryptoCurrencyFactory { fun create(userWallet: UserWallet, chainId: Int): Either - fun create(userWallet: UserWallet): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index dac433e459..67942f98f2 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -1,5 +1,7 @@ package com.tangem.domain.pay.model +import com.tangem.domain.models.pay.TangemPayCardLimit +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.visa.model.TangemPayCardFrozenState @@ -51,6 +53,9 @@ data class CustomerInfo( val id: String, val cardId: String, val frozenState: TangemPayCardFrozenState, + val displayName: CardDisplayName?, + val actualCardLimit: TangemPayCardLimit?, + val adminCardLimit: TangemPayCardLimit?, val status: Status, ) { enum class Status { diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index 327d8fd61f..fd3d717107 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -1,7 +1,6 @@ package com.tangem.domain.pay.model enum class OrderStatus { - UNKNOWN, // TODO remove it after TangemPay accounts refactor TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED NEW, PROCESSING, COMPLETED, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt new file mode 100644 index 0000000000..45945f7204 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.pay.model + +data class TangemPayReissueOrderInfo( + val orderId: String, + val orderStatus: OrderStatus, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index 8fcab526ee..bce59b45c7 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -2,7 +2,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError -import com.tangem.domain.models.TangemPayEligibilityType +import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.visa.error.VisaApiError diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 088b34d193..6778223272 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance @@ -31,4 +32,16 @@ interface TangemPayCardDetailsRepository { fun cardFrozenState(cardId: String): Flow suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? + + suspend fun updateCardDisplayName( + cardId: String, + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either + + suspend fun updateCardLimit( + cardId: String, + userWalletId: UserWalletId, + limit: String, + ): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt new file mode 100644 index 0000000000..3f756df4ee --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.pay.TangemPayReissueCardFee +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.visa.error.VisaApiError + +interface TangemPayReissueCardRepository { + + suspend fun getReissueCardFee(userWalletId: UserWalletId): Either + + suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either + + suspend fun storeReissueOrderId(cardId: String, orderId: String): Either + + suspend fun getReissueOrderInfo( + userWalletId: UserWalletId, + cardId: String, + ): Either +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt new file mode 100644 index 0000000000..acc42bd153 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Option +import arrow.core.none +import arrow.core.some +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull + +class GetPaymentAccountCryptoCurrencyStatusUseCase( + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, +) { + + operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Flow> { + return paymentAccountStatusSupplier(userWalletId).mapNotNull { accountStatus -> + val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + else -> return@mapNotNull null + } + if (cryptoCurrencyStatus.currency == cryptoCurrency) { + accountStatus.account to cryptoCurrencyStatus + } else { + null + } + } + } + + suspend fun invokeSync( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Option> { + val accountStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() ?: return none() + val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + else -> return none() + } + return if (cryptoCurrencyStatus.currency == cryptoCurrency) { + (accountStatus.account to cryptoCurrencyStatus).some() + } else { + none() + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt new file mode 100644 index 0000000000..9c66f8d75e --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import java.math.BigDecimal + +class SetTangemPayCardLimitUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, +) { + suspend operator fun invoke( + cardId: String, + userWalletId: UserWalletId, + amount: BigDecimal, + ): Either { + return cardDetailsRepository.updateCardLimit(cardId, userWalletId, amount.toPlainString()) + .onRight { + val params = PaymentAccountStatusFetcher.Params(userWalletId) + paymentAccountStatusFetcher.invoke(params) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt deleted file mode 100644 index 2e62f7190e..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.tangem.domain.pay.usecase - -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayEligibilityManager -import com.tangem.domain.pay.model.* -import com.tangem.domain.pay.repository.CustomerOrderRepository -import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.domain.visa.error.VisaApiError -import com.tangem.security.DeviceSecurityInfoProvider -import com.tangem.security.isSecurityExposed -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.flow.* - -class TangemPayMainScreenCustomerInfoUseCase( - private val onboardingRepository: OnboardingRepository, - private val customerOrderRepository: CustomerOrderRepository, - private val eligibilityManager: TangemPayEligibilityManager, - private val deviceSecurity: DeviceSecurityInfoProvider, -) { - - val state: StateFlow>> - field = MutableStateFlow(value = mapOf()) - - private val logger = TangemLogger.withTag("TangemPayMainScreenCustomerInfoUseCase") - - suspend fun fetch(userWalletId: UserWalletId) { - logger.i("fetch: ${userWalletId.stringValue}") - - if (onboardingRepository.isTangemPayDeactivated(userWalletId)) { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - return - } - - if (deviceSecurity.isSecurityExposed()) { - logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}") - logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}") - logger.i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}") - - updateState(userWalletId = userWalletId, either = TangemPayCustomerInfoError.ExposedDeviceError.left()) - return // fast exit - } - - onboardingRepository.hasTangemPayInWallet(userWalletId) - .fold( - ifLeft = { error -> - logger.e("Failed checkCustomerWallet for $userWalletId: ${error.javaClass.simpleName}") - if (error is VisaApiError.NotPaeraCustomer) { - showOnboardingBannerIfEligible(userWalletId) - } else { - updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left()) - } - }, - ifRight = { hasTangemPay -> - logger.i("checkCustomerWallet for $userWalletId: $hasTangemPay") - if (hasTangemPay) { - val oldResult = state.value[userWalletId] - if (oldResult == null) { - updateState(userWalletId, MainCustomerInfoContentState.Loading.right()) - } - - val result = proceedWithPaeraCustomerResult(userWalletId) - updateState(userWalletId, result.map(MainCustomerInfoContentState::Content)) - } else { - // if there's no tangem pay, check eligibility and show onboarding banner - showOnboardingBannerIfEligible(userWalletId) - } - }, - ) - } - - private suspend fun showOnboardingBannerIfEligible(userWalletId: UserWalletId) { - val tangemPayEntryPoint = TangemPayEntryPoint.BANNER - if (eligibilityManager.isPaeraCustomerForAnyWallet(tangemPayEntryPoint)) { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - return - } - val isEligible = eligibilityManager - .getEligibleWallets( - shouldExcludePaeraCustomers = false, - entryPoint = tangemPayEntryPoint, - ) - .any { it.walletId == userWalletId } - if (isEligible) { - if (onboardingRepository.getHideMainOnboardingBanner(userWalletId)) { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - } else { - updateState(userWalletId, MainCustomerInfoContentState.OnboardingBanner.right()) - } - } else { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - } - } - - operator fun invoke( - userWalletId: UserWalletId, - ): Flow> { - return state.mapNotNull { map -> map[userWalletId] } - } - - private fun updateState( - userWalletId: UserWalletId, - either: Either, - ) { - state.update { currentMap -> - currentMap.toMutableMap().apply { this[userWalletId] = either } - } - } - - private suspend fun proceedWithPaeraCustomerResult( - userWalletId: UserWalletId, - ): Either { - if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) { - return TangemPayCustomerInfoError.RefreshNeededError.left() - } - val orderId = onboardingRepository.getOrderId(userWalletId) - return if (orderId != null) { - proceedWithOrderId(userWalletId = userWalletId, orderId = orderId) - } else { - proceedWithoutOrder(userWalletId = userWalletId) - } - } - - private suspend fun proceedWithoutOrder( - userWalletId: UserWalletId, - ): Either { - return onboardingRepository.getCustomerInfo(userWalletId) - .mapLeft { error -> - logger.e("mapErrorForCustomer: $error") - error.mapErrorForCustomer() - } - .map { customerInfo -> - logger.i("customerInfo") - if (customerInfo.productInstance == null) { - onboardingRepository.createOrder(userWalletId) - MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.NEW) - } else { - MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.COMPLETED) - } - } - } - - private suspend fun proceedWithOrderId( - userWalletId: UserWalletId, - orderId: String, - ): Either { - return customerOrderRepository.getOrderData(userWalletId, orderId = orderId) - .fold( - ifLeft = { error -> - error.mapErrorForCustomer().left() - }, - ifRight = { orderData -> - if (orderData.status in setOf(OrderStatus.COMPLETED, OrderStatus.UNKNOWN)) { - onboardingRepository.clearOrderId(userWalletId) - } - onboardingRepository.getCustomerInfo(userWalletId = userWalletId) - .mapLeft { it.mapErrorForCustomer() } - .map { customerInfo -> - MainScreenCustomerInfo(info = customerInfo, orderStatus = orderData.status) - } - }, - ) - } - - private fun VisaApiError.mapErrorForCustomer(): TangemPayCustomerInfoError { - return when (this) { - is VisaApiError.RefreshTokenExpired -> TangemPayCustomerInfoError.RefreshNeededError - is VisaApiError.NotPaeraCustomer -> TangemPayCustomerInfoError.UnknownError - else -> TangemPayCustomerInfoError.UnavailableError - } - } -} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index f7eb452cc2..03db2b8e49 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -1,6 +1,7 @@ package com.tangem.domain.tangempay import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AppsFlyerIncludedEvent sealed class TangemPayAnalyticsEvents( categoryName: String, @@ -11,7 +12,7 @@ sealed class TangemPayAnalyticsEvents( class ActivationScreenOpened : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa Activation Screen Opened", - ) + ), AppsFlyerIncludedEvent class ViewTermsClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", @@ -21,12 +22,12 @@ sealed class TangemPayAnalyticsEvents( class GetCardClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Button - Visa Get Card", - ) + ), AppsFlyerIncludedEvent class KycFlowOpened : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa KYC Flow Opened", - ) + ), AppsFlyerIncludedEvent class IssuingBannerDisplayed : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", @@ -41,17 +42,17 @@ sealed class TangemPayAnalyticsEvents( class ReceiveFundsClicked : TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Receive", - ) + ), AppsFlyerIncludedEvent class AddFundsClicked : TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Add Funds", - ) + ), AppsFlyerIncludedEvent class SwapClicked : TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Swap", - ) + ), AppsFlyerIncludedEvent class ChooseWalletPopup : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", @@ -180,7 +181,7 @@ sealed class TangemPayAnalyticsEvents( class KycPassedAndOrderCreated : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa KYC Passed And Order Created", - ) + ), AppsFlyerIncludedEvent class KycRejected : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", @@ -192,6 +193,21 @@ sealed class TangemPayAnalyticsEvents( event = "Visa KYC Canceled", ) + class ReplaceCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Visa Replace Card Clicked", + ) + + class ReplaceCardConfirmationPopupOpened : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Visa Replace Card Confirmation Popup Opened", + ) + + class ReplaceCardConfirmed : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Visa Replace Card Confirmed", + ) + class MainVisaPermanentBannerClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa Permanent Banner Clicked", diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcBitcoinMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcBitcoinMethod.kt new file mode 100644 index 0000000000..f989194c01 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcBitcoinMethod.kt @@ -0,0 +1,93 @@ +package com.tangem.domain.walletconnect.model + +/** + * Bitcoin WalletConnect method names. + * + * @see Bitcoin RPC Reference + */ +enum class WcBitcoinMethodName(override val raw: String) : WcMethodName { + SendTransfer("sendTransfer"), + GetAccountAddresses("getAccountAddresses"), + SignPsbt("signPsbt"), + SignMessage("signMessage"), +} + +/** + * Bitcoin WalletConnect methods. + */ +sealed interface WcBitcoinMethod : WcMethod { + val methodName: String + + /** + * Send a Bitcoin transfer transaction. + * + * @property account Source address (SegWit) + * @property recipientAddress Destination address + * @property amount Amount in satoshis + * @property memo Optional OP_RETURN memo + * @property changeAddress Optional custom change address + */ + data class SendTransfer( + val account: String, + val recipientAddress: String, + val amount: String, + val memo: String?, + val changeAddress: String?, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.SendTransfer.raw + } + + /** + * Get account addresses filtered by intention. + * + * @property account Connected account address + * @property intentions Optional filter ("payment", "ordinal") + */ + data class GetAccountAddresses( + val account: String, + val intentions: List?, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.GetAccountAddresses.raw + } + + /** + * Sign a PSBT (BIP-174). + * + * @property psbt PSBT in Base64 encoding + * @property signInputs List of inputs to sign + * @property shouldBroadcast Whether to broadcast after signing + */ + data class SignPsbt( + val psbt: String, + val signInputs: List, + val shouldBroadcast: Boolean, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.SignPsbt.raw + } + + /** + * Sign input specification for PSBT. + */ + data class SignInput( + val address: String, + val index: Int, + val sighashTypes: List?, + ) + + /** + * Sign an arbitrary message using Bitcoin message signing format. + * + * @property account Connected account address + * @property message Message to sign + * @property address Optional specific address to sign with + * @property protocol Signing protocol ("ecdsa" or "bip322") + */ + data class SignMessage( + val account: String, + val message: String, + val address: String?, + val protocol: String, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.SignMessage.raw + } +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcLogTag.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcLogTag.kt new file mode 100644 index 0000000000..5fb6a63542 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcLogTag.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.walletconnect + +/** + * Common log tag for all WalletConnect-related logging across modules. + */ +const val WC_TAG = "WalletConnect" \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcTransactionSignerProvider.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcTransactionSignerProvider.kt new file mode 100644 index 0000000000..7cecd888b9 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcTransactionSignerProvider.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.walletconnect + +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.domain.models.wallet.UserWallet + +/** + * Provider for creating transaction signers for WalletConnect operations. + * + * This interface abstracts the creation of [TransactionSigner] instances + * to avoid direct dependency on card SDK configuration in wallet-connect module. + */ +interface WcTransactionSignerProvider { + + /** + * Creates a transaction signer for the given wallet. + * + * @param wallet The user wallet to create a signer for + * @return A [TransactionSigner] instance for the wallet + */ + fun createSigner(wallet: UserWallet): TransactionSigner +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/featuretoggle/WalletConnectFeatureToggles.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/featuretoggle/WalletConnectFeatureToggles.kt new file mode 100644 index 0000000000..a48906360d --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/featuretoggle/WalletConnectFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.walletconnect.featuretoggle + +interface WalletConnectFeatureToggles { + val isBitcoinEnabled: Boolean +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcGetAddressesUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcGetAddressesUseCase.kt new file mode 100644 index 0000000000..5cdbd444fd --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcGetAddressesUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.walletconnect.usecase.method + +import arrow.core.Either +import com.tangem.domain.walletconnect.model.HandleMethodError + +/** + * Base use case for WalletConnect methods that return wallet addresses. + * + * This is a non-signing operation that returns addresses immediately. + */ +interface WcGetAddressesUseCase : WcMethodUseCase, WcMethodContext { + + /** + * Get wallet addresses. + * + * @return Either error or list of addresses with their metadata + */ + suspend operator fun invoke(): Either + + /** + * Reject the request. + */ + fun reject() + + /** + * Result containing wallet addresses. + */ + data class GetAddressesResult( + val addresses: List, + ) + + /** + * Address information. + */ + data class AddressInfo( + val address: String, + val publicKey: String?, + val path: String?, + val intention: String?, + ) +} \ No newline at end of file diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index 04112d2530..b1f17fb70a 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -12,7 +12,6 @@ import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.SimpleResult import com.tangem.blockchain.nft.models.NFTAsset import com.tangem.blockchain.nft.models.NFTCollection -import com.tangem.blockchain.tokenbalance.models.TokenBalance import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -39,12 +38,14 @@ interface WalletManagersFacade { * @param userWalletId The ID of the user's wallet. * @param network The network. * @param extraTokens Additional tokens. + * @param xpub XPUB string to restore dynamic addresses mode if not yet active. * @return The result of updating the wallet manager. */ suspend fun update( userWalletId: UserWalletId, network: Network, extraTokens: Set, + xpub: String? = null, ): UpdateWalletManagerResult /** @@ -282,8 +283,6 @@ interface WalletManagersFacade { suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? - suspend fun getTokenBalances(userWalletId: UserWalletId, network: Network): List - /** * If wallet manager implements [InitializableAccount] then returns [InitializableAccount.isAccountInitialized] * value. Otherwise always return true @@ -296,11 +295,22 @@ interface WalletManagersFacade { suspend fun disableXpubMode(userWalletId: UserWalletId, network: Network): SimpleResult + suspend fun isDynamicAddressesEnabled(userWalletId: UserWalletId, network: Network): Boolean + suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? suspend fun getDynamicAddressesLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String? suspend fun hasDynamicAddressesNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean + /** + * Silently probes the xpub for balances on non-base derived addresses. + * Does not mutate wallet manager state; can be called when dynamic addresses mode is disabled. + * + * @return true if any non-base derived address has a non-zero balance, false on probe failure, + * when the network doesn't support dynamic addresses, or when no extra funds were found. + */ + suspend fun probeHasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network, xpub: String): Boolean + // endregion Dynamic Addresses } \ No newline at end of file diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/WalletSyncResult.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/WalletSyncResult.kt new file mode 100644 index 0000000000..48c7caae72 --- /dev/null +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/WalletSyncResult.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.wallets.models + +enum class WalletSyncResult { + AlreadyExists, + Created, +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt index 9eefdc6899..a3ee510fde 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt @@ -29,6 +29,9 @@ interface DerivationsRepository { derivations: Map>, ): Map + /** Returns already derived extended public keys for the given [seedKey] */ + suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap + /** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWalletId: UserWalletId, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index 2b25388b1e..33686eea83 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.models.UserWalletRemoteInfo +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import kotlinx.coroutines.flow.Flow @@ -22,7 +23,7 @@ interface WalletsRepository { suspend fun setHasWalletsWithRing(userWalletId: UserWalletId) - suspend fun createWallet(userWalletId: UserWalletId) + suspend fun createWallet(userWalletId: UserWalletId): WalletSyncResult fun nftEnabledStatus(userWalletId: UserWalletId): Flow diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt index 4037df2361..431d52cb5f 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.DeleteWalletError import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.logging.TangemLogger /** * Use case for deleting user wallet @@ -24,8 +25,10 @@ class DeleteWalletUseCase( * @return [Either] with [com.tangem.domain.common.wallets.error.DeleteWalletError] or [Boolean] which indicates that there are still saved wallets. * */ suspend operator fun invoke(userWalletId: UserWalletId): Either { - return userWalletsListRepository.delete(userWalletIds = listOf(userWalletId)).map { - userWalletsListRepository.selectedUserWallet.value != null - } + return userWalletsListRepository.delete(userWalletIds = listOf(userWalletId)) + .map { userWalletsListRepository.selectedUserWallet.value != null } + .onLeft { + TangemLogger.e("Failed to delete wallet with id ${userWalletId.value}: $it") + } } } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt index e548eef8b7..100979e021 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt @@ -1,7 +1,6 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either -import arrow.core.right import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.ByteArrayKey @@ -38,23 +37,37 @@ class GetExtendedPublicKeyForCurrencyUseCase( error("No derivation found") } + val seedKey = walletManager.wallet.publicKey.seedKey + val existingKeys = derivationsRepository.getExistingDerivedKeys( + userWalletId = userWalletId, + seedKey = ByteArrayKey(seedKey), + ) + var childKey = makeChildKey( isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(), extendedPublicKey = hdKey.extendedPublicKey, derivationPath = hdKey.path, ) + // Fill from already derived keys if available + if (childKey.extendedPublicKey == null) { + existingKeys[childKey.derivationPath]?.let { + childKey = childKey.copy(extendedPublicKey = it) + } + } + + val parentPath = childKey.derivationPath.dropLastNodes(1) var parentKey = Key( - derivationPath = childKey.derivationPath.dropLastNodes(1), - extendedPublicKey = null, + derivationPath = parentPath, + extendedPublicKey = existingKeys[parentPath], ) val pendingDerivations = getPendingDerivations(childKey, parentKey) - val derivedKeys = deriveKeys( - userWalletId = userWalletId, - seedKey = walletManager.wallet.publicKey.seedKey, - paths = pendingDerivations, - ) + val derivedKeys = if (pendingDerivations.isNotEmpty()) { + deriveKeys(userWalletId = userWalletId, seedKey = seedKey, paths = pendingDerivations) + } else { + ExtendedPublicKeysMap(emptyMap()) + } if (childKey.extendedPublicKey == null) { childKey = childKey.copy( @@ -72,22 +85,6 @@ class GetExtendedPublicKeyForCurrencyUseCase( } } - /** - * @return true if xpub generation is supported, false otherwise - */ - suspend fun isSupported(userWalletId: UserWalletId, network: Network): Either = Either.catch { - val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) - ?: error("Wallet not found for user wallet $userWalletId and network ${network.id}") - - val blockchain = network.toBlockchain() - val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain) - val isHdKey = walletManager.wallet.publicKey.derivationType?.hdKey - - val isSupported = isSecp256k1Blockchain && isHdKey != null - - return isSupported.right() - } - private suspend fun deriveKeys( userWalletId: UserWalletId, seedKey: ByteArray, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt index 1a436c9f93..355ce124c1 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt @@ -3,9 +3,9 @@ package com.tangem.domain.wallets.usecase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import java.util.LinkedHashMap /** * Use case for getting list of user wallets @@ -22,9 +22,13 @@ class GetWalletsUseCase( operator fun invoke(): Flow> = userWalletsListRepository.userWallets.map { requireNotNull(it) } @Throws(IllegalArgumentException::class) - fun invokeAsMap(): Flow> = userWalletsListRepository.userWallets - .map { requireNotNull(it) } - .map { wallets -> + fun invokeAsMap(isOnlyMultiCurrency: Boolean = true): Flow> = invoke() + .map { list -> + val wallets = if (isOnlyMultiCurrency) { + list.filter { wallet -> wallet.isMultiCurrency } + } else { + list + } wallets.associateByTo( destination = linkedMapOf(), keySelector = { wallet -> wallet.walletId }, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt index 239ada3255..98f3317583 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt @@ -5,25 +5,23 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SelectWalletError import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.redux.ReduxStateHolder /** - * Use case for selecting wallet + * Use case for selecting wallet. + * + * Side effects tied to selection (analytics tracking context, Tangem SDK display config, access + * code request policy) are fired from the repository itself when the selected [UserWalletId] + * changes — see the implementation of [UserWalletsListRepository.select]. * * @property userWalletsListRepository repository for getting list of user wallets - * @property reduxStateHolder redux state holder * [REDACTED_AUTHOR] */ class SelectWalletUseCase( private val userWalletsListRepository: UserWalletsListRepository, - private val reduxStateHolder: ReduxStateHolder, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either { - return userWalletsListRepository.select(userWalletId).map { - reduxStateHolder.onUserWalletSelected(it) - it - } + return userWalletsListRepository.select(userWalletId) } } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt index 063dd3380d..82ebc8e397 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt @@ -1,6 +1,7 @@ package com.tangem.domain.wallets.usecase import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger @@ -14,8 +15,9 @@ class SyncWalletWithRemoteUseCase( private val walletsRepository: WalletsRepository, ) { - suspend operator fun invoke(userWalletId: UserWalletId) { - runSuspendCatching { walletsRepository.createWallet(userWalletId) } + suspend operator fun invoke(userWalletId: UserWalletId): WalletSyncResult { + return runSuspendCatching { walletsRepository.createWallet(userWalletId) } .onFailure { TangemLogger.e("Error", it) } + .getOrDefault(WalletSyncResult.AlreadyExists) } } \ No newline at end of file diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts index ea83aa3225..86c16fe968 100644 --- a/domain/yield-supply/build.gradle.kts +++ b/domain/yield-supply/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { /** Core */ implementation(projects.core.ui) implementation(projects.core.utils) + implementation(projects.libs.blockchainSdk) /** Domain */ implementation(projects.domain.account.status) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt index 35cb09af2c..c5e83841ac 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt @@ -5,6 +5,7 @@ import arrow.core.Either.Companion.catch import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCoin import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -67,8 +68,7 @@ class YieldSupplyGetCurrentFeeUseCase( val tokenValue = rateRatio.multiply(nativeGas.amount.value) - val isEthereum = cryptoCurrencyStatus.currency - .network.id.rawId.value == Blockchain.Ethereum.id + val isEthereum = cryptoCurrencyStatus.currency.network.rawId == Blockchain.Ethereum.toNetworkId() val isHighFee = if (isEthereum) { val maxFeePerGas = (feeWithoutGas as? Fee.Ethereum.EIP1559)?.maxFeePerGas ?: 0.toBigInteger() diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt index c26f6ba501..03680be520 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt @@ -165,8 +165,7 @@ class YieldSupplyMinAmountUseCaseTest { private fun createNetwork(): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID("polygon"), derivationPath), - backendId = "polygon", + id = Network.ID(value = "polygon", derivationPath = derivationPath), name = "Polygon", currencySymbol = "MATIC", derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt index d3f2581274..e8f69ad3c5 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt @@ -7,8 +7,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import io.mockk.coEvery import io.mockk.coVerify @@ -324,7 +324,6 @@ class YieldSupplyEnterStatusUseCaseTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt index f36d85dfaa..39ab76e2a6 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt @@ -2,7 +2,7 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.Blockchain + import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.supplier.SingleAccountListSupplier @@ -48,7 +48,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN valid inputs on non-ethereum WHEN invoke THEN returns fee value and not high`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val tokenDecimals = 8 val nativeDecimals = 18 val token = createToken(rawNetworkId = rawNetworkId, decimals = tokenDecimals) @@ -100,7 +100,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN ethereum with high gas WHEN invoke THEN returns high fee flag`() = runTest { - val rawNetworkId = Blockchain.Ethereum.id + val rawNetworkId = "ethereum" val tokenDecimals = 8 val nativeDecimals = 18 val token = createToken(rawNetworkId = rawNetworkId, decimals = tokenDecimals) @@ -152,7 +152,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN token fiat rate missing WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = null) @@ -175,7 +175,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN native quotes unavailable WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal("2.00")) val nativeCoin = createCoin(rawNetworkId = rawNetworkId, decimals = 18) @@ -204,7 +204,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN empty quotes list WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal("2.00")) val nativeCoin = createCoin(rawNetworkId = rawNetworkId, decimals = 18) @@ -233,7 +233,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN native fiat rate non-positive WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal("2.00")) val nativeCoin = createCoin(rawNetworkId = rawNetworkId, decimals = 18) @@ -274,7 +274,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN token fiat rate non-positive WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal.ZERO) // non-positive @@ -299,7 +299,6 @@ class YieldSupplyGetCurrentFeeUseCaseTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, @@ -331,7 +330,6 @@ class YieldSupplyGetCurrentFeeUseCaseTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt index 67c35ae602..d8defa5bea 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt @@ -61,8 +61,7 @@ class YieldSupplyGetDustMinAmountUseCaseTest { private fun createNetwork(): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID("polygon"), derivationPath), - backendId = "polygon", + id = Network.ID(value = "polygon", derivationPath = derivationPath), name = "Polygon", currencySymbol = "MATIC", derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt index 6d40c26c08..01534f3b92 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt @@ -238,8 +238,7 @@ class YieldSupplyGetRewardsBalanceUseCaseTest { @Test fun `GIVEN polygon USDT0 Loaded status WHEN invoke THEN emit formatted balances`() = runTest { val network = Network( - id = Network.ID(Network.RawID("POLYGON"), Network.DerivationPath.Card("m/44'/60'/0'/0/0")), - backendId = "polygon-pos", + id = Network.ID(value = "polygon-pos", derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/0")), name = "Polygon", currencySymbol = "POL", derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/0"), @@ -365,8 +364,7 @@ class YieldSupplyGetRewardsBalanceUseCaseTest { private fun createNetwork(): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID("polygon"), derivationPath), - backendId = "polygon", + id = Network.ID(value = "polygon", derivationPath = derivationPath), name = "Polygon", currencySymbol = "MATIC", derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt index e1c0349515..81fa8b8a84 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt @@ -279,7 +279,6 @@ class YieldSupplyPendingTrackerTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = networkId, derivationPath = derivationPath), - backendId = networkId, name = networkId, currencySymbol = networkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt index 782341c8e1..7f4ec9b4c5 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider @@ -27,6 +28,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.accounts.ArchivedAccountsScreenTestTags import com.tangem.features.account.archived.entity.AccountArchivedUM import com.tangem.features.account.archived.entity.ArchivedAccountUM import kotlinx.collections.immutable.toImmutableList @@ -102,7 +104,7 @@ private fun ArchiveAccountError(state: AccountArchivedUM.Error, modifier: Modifi @Composable private fun ArchiveAccountContent(state: AccountArchivedUM.Content, modifier: Modifier = Modifier) { - LazyColumn(modifier = modifier) { + LazyColumn(modifier = modifier.testTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNTS_SCREEN_CONTAINER)) { itemsIndexed( items = state.accounts, key = { index, item -> item.accountId }, @@ -127,7 +129,8 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod modifier = modifier .fillMaxWidth() .clickable(enabled = !item.isLoading, onClick = item.onClick) - .padding(all = TangemTheme.dimens.spacing12), + .padding(all = TangemTheme.dimens.spacing12) + .testTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNT_ITEM), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { @@ -151,6 +154,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod isLoading = item.isLoading, onClick = item.onClick, ), + modifier = Modifier.testTag(ArchivedAccountsScreenTestTags.RESTORE_BUTTON), ) } } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt index 792db8d675..1dc4a29253 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -40,6 +41,7 @@ import com.tangem.core.ui.components.fields.AutoSizeTextField import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.accounts.AccountInfoEditScreenTestTags import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.account.createedit.entity.AccountCreateEditUM @@ -128,6 +130,8 @@ private fun AccountSummary(account: Account, isCreateMode: Boolean) { name = account.name.value, icon = account.portfolioIcon, size = AccountIconSize.Large, + modifier = Modifier + .testTag(AccountInfoEditScreenTestTags.SELECTED_ICON), ) Spacer(modifier = Modifier.height(24.dp)) @@ -159,7 +163,9 @@ private fun AccountSummary(account: Account, isCreateMode: Boolean) { account.onNameChange(newName) }, - textFieldModifier = Modifier.focusRequester(focusRequester), + textFieldModifier = Modifier + .focusRequester(focusRequester) + .testTag(AccountInfoEditScreenTestTags.NAME_FIELD), ) SpacerH(20.dp) } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt index b8a0613c61..e764b00d99 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -32,7 +32,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.AccountDetailsScreenTestTags +import com.tangem.core.ui.test.accounts.AccountDetailsScreenTestTags import com.tangem.features.account.details.entity.AccountDetailsUM @Composable @@ -42,7 +42,8 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = .background(color = TangemTheme.colors.background.secondary) .fillMaxSize() .imePadding() - .systemBarsPadding(), + .systemBarsPadding() + .testTag(AccountDetailsScreenTestTags.ACCOUNT_DETAILS_CONTAINER), horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithBackButton( @@ -95,7 +96,8 @@ private fun ArchiveAccountRow(state: AccountDetailsUM.ArchiveMode.Available) { .clip(RoundedCornerShape(TangemTheme.dimens.radius12)) .background(TangemTheme.colors.background.primary) .clickable(enabled = !state.isLoading, onClick = state.onArchiveAccountClick) - .padding(all = TangemTheme.dimens.spacing12), + .padding(all = TangemTheme.dimens.spacing12) + .testTag(AccountDetailsScreenTestTags.ARCHIVE_ACCOUNT_BUTTON), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { @@ -160,7 +162,8 @@ private fun AccountRow(state: AccountDetailsUM) { .clip(RoundedCornerShape(TangemTheme.dimens.radius12)) .background(TangemTheme.colors.background.primary) .clickable(onClick = state.onAccountEditClick) - .padding(all = TangemTheme.dimens.spacing12), + .padding(all = TangemTheme.dimens.spacing12) + .testTag(AccountDetailsScreenTestTags.EDIT_ACCOUNT_BUTTON), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { diff --git a/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt b/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt index 9f77e55922..29320dd427 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt @@ -3,15 +3,9 @@ package com.tangem.features.account.di import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.ArchivedAccountListComponent -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController import com.tangem.features.account.archived.DefaultArchivedAccountListComponent import com.tangem.features.account.createedit.DefaultAccountCreateEditComponent import com.tangem.features.account.details.DefaultAccountDetailsComponent -import com.tangem.features.account.fetcher.DefaultPortfolioFetcher -import com.tangem.features.account.selector.DefaultPortfolioSelectorComponent -import com.tangem.features.account.selector.DefaultPortfolioSelectorController import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,17 +15,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) internal interface AccountFeatureModule { - @Binds - fun bindPortfolioFetcherFactory(impl: DefaultPortfolioFetcher.Factory): PortfolioFetcher.Factory - - @Binds - fun bindPortfolioSelectorController(impl: DefaultPortfolioSelectorController): PortfolioSelectorController - - @Binds - fun bindPortfolioSelectorComponentFactory( - impl: DefaultPortfolioSelectorComponent.Factory, - ): PortfolioSelectorComponent.Factory - @Binds fun bindAccountCreateEditComponentFactory( impl: DefaultAccountCreateEditComponent.Factory, diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index 2161f8c8a1..108efdb33c 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionSender.MultipleTransactionSendMode import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -16,10 +17,7 @@ import com.tangem.core.analytics.models.Basic import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError @@ -38,7 +36,6 @@ import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.entity.FeeItem import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger -import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.MutableStateFlow @@ -62,7 +59,6 @@ internal class GiveApprovalModel @Inject constructor( private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase, private val getFeeForTokenUseCase: GetFeeForTokenUseCase, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, - private val uiMessageSender: UiMessageSender, private val urlOpener: UrlOpener, private val getUserWalletUseCase: GetUserWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, @@ -126,16 +122,9 @@ internal class GiveApprovalModel @Inject constructor( } fun onOpenLearnMoreAboutApproveClick() { - urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP) - } - - fun showPermissionInfoDialog() { - uiMessageSender.send( - DialogMessage( - message = resourceReference(com.tangem.common.ui.R.string.give_permission_staking_footer), - title = resourceReference(com.tangem.common.ui.R.string.common_approve), - ), - ) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission)) + } } suspend fun loadFee(): Either { diff --git a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt index fc097acb8c..cf09525c4c 100644 --- a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt +++ b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt @@ -41,7 +41,6 @@ class GiveApprovalModelTest { private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk(relaxed = true) private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true) - private val uiMessageSender: UiMessageSender = mockk(relaxed = true) private val urlOpener: UrlOpener = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) @@ -81,7 +80,6 @@ class GiveApprovalModelTest { getFeeForGaslessUseCase = getFeeForGaslessUseCase, getFeeForTokenUseCase = getFeeForTokenUseCase, createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, - uiMessageSender = uiMessageSender, urlOpener = urlOpener, getUserWalletUseCase = getUserWalletUseCase, analyticsEventHandler = analyticsEventHandler, diff --git a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt index a3726bf381..447e3a1238 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt @@ -126,6 +126,16 @@ private fun Footer(state: AskBiometryUM, modifier: Modifier = Modifier) { .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { + Text( + modifier = Modifier.fillMaxWidth(fraction = .7f), + text = stringResourceSafe(R.string.save_user_wallet_agreement_notice), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + + SpacerH16() + PrimaryButton( modifier = Modifier.fillMaxWidth(), showProgress = state.shouldShowProgress, @@ -142,16 +152,6 @@ private fun Footer(state: AskBiometryUM, modifier: Modifier = Modifier) { onClick = state.onDontAllowClick, ) } - - SpacerH16() - - Text( - modifier = Modifier.fillMaxWidth(fraction = .7f), - text = stringResourceSafe(R.string.save_user_wallet_agreement_notice), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - ) } } diff --git a/features/common-features/api/.gitignore b/features/common-features/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/common-features/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/common-features/api/build.gradle.kts b/features/common-features/api/build.gradle.kts new file mode 100644 index 0000000000..2d20ac5683 --- /dev/null +++ b/features/common-features/api/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.features.commonfeatures.api" +} + +dependencies { + + /* Project - Domain */ + implementation(projects.domain.models) + implementation(projects.domain.markets) + implementation(projects.domain.account) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) + implementation(deps.kotlin.immutable.collections) +} \ No newline at end of file diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt new file mode 100644 index 0000000000..d68a5f6d19 --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt @@ -0,0 +1,11 @@ +package com.tangem.features.commonfeatures.api.addtoportfolio + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent + +interface AddToPortfolioComponent : ComposableBottomSheetComponent { + + data class Params(val addToPortfolioManager: AddToPortfolioManager) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt new file mode 100644 index 0000000000..b4d0f52a3f --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -0,0 +1,115 @@ +package com.tangem.features.commonfeatures.api.addtoportfolio + +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable + +interface AddToPortfolioManager : AddToPortfolioManagerInternal { + + val onDismiss: Channel + val onSuccessAdded: Channel + val onAddedTokenClick: Channel + + val state: StateFlow + + /** + * default is [LaunchMode.DirectAdd] + */ + fun updateLaunchMode(launchMode: LaunchMode) + + fun setTokenNetworks(networks: List) + fun setTokenParams(token: RawMarketToken) + fun setTokenParams(token: TokenMarketParams) = setTokenParams( + RawMarketToken( + id = token.id, + name = token.name, + symbol = token.symbol, + ), + ) + + sealed interface State { + data object Loading : State + data class Ready(val availableToAddData: AvailableToAddData) : State { + val isAvailableToAdd: Boolean get() = availableToAddData.isAvailableToAdd + val isSinglePortfolio: Boolean get() = availableToAddData.isSinglePortfolio + } + } + + @Serializable + data class AnalyticsParams( + val source: String?, + val category: String = CategoryDefault, + ) { + companion object { + const val CategoryDefault = "Markets / Chart" + const val CategoryEarn = "Earn" + } + } + + interface Factory { + fun create(scope: CoroutineScope, settings: Settings, analyticsParams: AnalyticsParams): AddToPortfolioManager + } + + sealed interface LaunchMode { + data object DirectAdd : LaunchMode + data object Preselected : LaunchMode + data object ViaUserPortfolio : LaunchMode + } + + /** + * Immutable settings + */ + data class Settings( + val shouldSkipTokenActionsScreen: Boolean = false, + ) { + companion object { + val DefaultMarket = Settings(shouldSkipTokenActionsScreen = false) + val ChooseToken = Settings(shouldSkipTokenActionsScreen = true) + val Earn = Settings(shouldSkipTokenActionsScreen = true) + } + } + + /** + * Mutable parameters + * Updates may trigger reload [State] + */ + data class Params( + val networks: List, + val token: RawMarketToken, + val launchMode: LaunchMode, + ) + + data class Result( + val wallet: UserWallet, + val account: AccountStatus.CryptoPortfolio, + val addedCurrency: CryptoCurrencyStatus, + ) +} + +/** + * primary for internal impl usage, but you can also use it externally + */ +interface AddToPortfolioManagerInternal { + val paramsFlow: SharedFlow + val settings: AddToPortfolioManager.Settings + val analyticsParams: AnalyticsParams + val portfolioFetcher: PortfolioFetcher + + suspend fun params(): AddToPortfolioManager.Params = paramsFlow.first() + suspend fun token(): RawMarketToken = params().token + + fun onDismiss() + fun onSuccessAdded(result: AddToPortfolioManager.Result) + fun onAddedTokenClick(result: AddToPortfolioManager.Result) +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AvailableToAddData.kt similarity index 93% rename from features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AvailableToAddData.kt index 4b491c0356..278f2d75a9 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AvailableToAddData.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add +package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.account.AccountId @@ -36,13 +36,13 @@ data class AvailableToAddAccount( get() = availableNetworks.size == 1 val availableToAddNetworks: Set = availableNetworks - .filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } } + .filter { available -> addedNetworks.none { added -> added.rawId == available.networkId } } .toSet() val isAvailableToAdd: Boolean = availableToAddNetworks.isNotEmpty() val addedMarketNetworks: Set = availableNetworks - .filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } } + .filter { available -> addedNetworks.any { added -> added.rawId == available.networkId } } .toSet() } diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt new file mode 100644 index 0000000000..596f8564aa --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt @@ -0,0 +1,98 @@ +package com.tangem.features.commonfeatures.api.choosetoken + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.api.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +interface ChooseTokenBridge : ChooseTokenBridgeInternal { + + val onCurrencyChosen: Channel + val onClose: Channel + + /** + * for some Feature specific tokens filtering + */ + val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> + + fun selectWalletTab(walletId: UserWalletId) + + data class Settings( + val title: TextReference, + val isShowMarketBlock: Boolean, + val isShowPaymentAccount: Boolean, + ) { + companion object { + val SwapFrom = Settings( + title = resourceReference(R.string.swapping_from_title), + isShowMarketBlock = true, + isShowPaymentAccount = true, + ) + val SwapTo = Settings( + title = resourceReference(R.string.swapping_to_title), + isShowMarketBlock = true, + isShowPaymentAccount = true, + ) + } + } + + interface Factory { + fun create( + modelScope: CoroutineScope, + settings: Settings, + analyticsPayload: Set = emptySet(), + ): ChooseTokenBridge + } +} + +/** + * primary for internal impl usage, but you can also use it externally + */ +interface ChooseTokenBridgeInternal { + val settings: ChooseTokenBridge.Settings + val analyticsPayload: Set + val searchQueryState: StateFlow + val fullPortfolioBlock: StateFlow + + fun onSearchQuery(query: SearchQuery) + fun onSearchQuery(query: String) = onSearchQuery(SearchQuery(query)) + + fun onClose() + fun onCurrencyChosen(result: ChooseTokenResult) + + @JvmInline + value class SearchQuery(val value: String) { + companion object { + val Empty = SearchQuery("") + val SearchQuery.isSearchingState: Boolean get() = this.value.isNotBlank() + val StateFlow.isSearchingState: Boolean get() = this.value.isSearchingState + } + } +} + +data class ChooseTokenResult( + val currency: CryptoCurrencyStatus, + val account: AccountStatus, + val wallet: UserWallet, + val analyticsPayload: Set = emptySet(), +) { + val walletId get() = wallet.walletId +} + +sealed interface ChooseTokenAnalyticsPayload { + + @Suppress("BooleanPropertyNaming") + @JvmInline + value class IsSearched(val value: Boolean) : ChooseTokenAnalyticsPayload + + @JvmInline + value class ScreensSources(val value: String) : ChooseTokenAnalyticsPayload +} \ No newline at end of file diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenComponent.kt new file mode 100644 index 0000000000..0aa01e30f1 --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenComponent.kt @@ -0,0 +1,13 @@ +package com.tangem.features.commonfeatures.api.choosetoken + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface ChooseTokenComponent : ComposableContentComponent { + + data class Params( + val bridge: ChooseTokenBridge, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/model/ChooseTokenPortfolioFullBlockUM.kt similarity index 56% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/model/ChooseTokenPortfolioFullBlockUM.kt index 950ef764ad..97a3274e8b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/model/ChooseTokenPortfolioFullBlockUM.kt @@ -1,21 +1,31 @@ -package com.tangem.feature.swap.models +package com.tangem.features.commonfeatures.api.choosetoken.model import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.feature.swap.models.market.state.SwapMarketState +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -internal data class SwapSelectTokenStateHolder( - val marketsState: SwapMarketState, - val tokensListData: TokenListUMData, +data class ChooseTokenPortfolioFullBlockUM( + val walletList: WalletListUM, val isBalanceHidden: Boolean, - val isAfterSearch: Boolean, - val onSearchEntered: (String) -> Unit, + val isSearching: Boolean, + val tokensListData: TokenListUMData, +) + +data class WalletListUM( + val items: ImmutableList, +) + +data class WalletTabUM( + val text: TextReference, + val count: TextReference?, + val isSelected: Boolean, + val onClick: () -> Unit, ) @Immutable -internal sealed interface TokenListUMData { +sealed interface TokenListUMData { val tokensList: ImmutableList val totalTokensCount: Int @@ -38,14 +48,4 @@ internal sealed interface TokenListUMData { private companion object { const val EMPTY_TOKENS_COUNT = 0 } -} - -internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean - get() = - tokensListData.tokensList.isEmpty() && isAfterSearch && - marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading - -internal val SwapSelectTokenStateHolder.isEmptyState: Boolean - get() = - tokensListData.tokensList.isEmpty() && !isAfterSearch && - marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading \ No newline at end of file +} \ No newline at end of file diff --git a/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioFetcher.kt similarity index 96% rename from features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioFetcher.kt index a56d06a5dd..2428a8c030 100644 --- a/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioFetcher.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account +package com.tangem.features.commonfeatures.api.portfolioselector import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.appcurrency.model.AppCurrency diff --git a/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt similarity index 97% rename from features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt index 7d8627e929..8d658a65ac 100644 --- a/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account +package com.tangem.features.commonfeatures.api.portfolioselector import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent diff --git a/features/common-features/impl/.gitignore b/features/common-features/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/common-features/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/common-features/impl/build.gradle.kts b/features/common-features/impl/build.gradle.kts new file mode 100644 index 0000000000..d7d2787421 --- /dev/null +++ b/features/common-features/impl/build.gradle.kts @@ -0,0 +1,93 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.commonfeatures.impl" +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + implementation(projects.features.wallet.api) + implementation(projects.features.tokenRecieve.api) + + /** Core modules */ + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.ui) + implementation(projects.core.error) + implementation(projects.core.res) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.datasource) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.markets) + implementation(projects.domain.transaction) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.manageTokens) + implementation(projects.domain.manageTokens.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + + /** Tangem libraries */ + implementation(tangemDeps.card.core) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.uiCharts) + implementation(projects.common.uiMarkets) + implementation(projects.common.routing) + + /** Libs */ + implementation(projects.libs.blockchainSdk) + + /** AndroidX libraries */ + implementation(deps.androidx.core.ktx) + implementation(deps.lifecycle.runtime.ktx) + + /** Compose libraries */ + implementation(deps.compose.material3) + implementation(deps.compose.animation) + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + + /** Other libraries */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.serialization) + implementation(deps.firebase.crashlytics) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) + testImplementation(projects.test.mock) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddToPortfolioBottomSheet.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt similarity index 89% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddToPortfolioBottomSheet.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt index 8c6aba3001..3f2288d1f4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddToPortfolioBottomSheet.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column @@ -19,9 +19,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes @Composable internal fun AddToPortfolioBottomSheet( @@ -63,7 +63,9 @@ internal fun AddToPortfolioBottomSheet( bottom = 16.dp, ) val isScrollableContent = when (animatedStack.active.configuration) { - AddToPortfolioRoutes.PortfolioSelector -> false + AddToPortfolioRoutes.PortfolioSelector, + AddToPortfolioRoutes.UserPortfolio, + -> false AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, is AddToPortfolioRoutes.NetworkSelector, @@ -95,6 +97,7 @@ private fun AddToPortfolioBottomSheetTitle( AddToPortfolioRoutes.Empty -> TextReference.EMPTY is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network) AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token) + AddToPortfolioRoutes.UserPortfolio -> resourceReference(R.string.markets_portfolio_block_title) AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent) .title.collectAsStateWithLifecycle().value } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetFooter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetFooter.kt new file mode 100644 index 0000000000..dac734d96d --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetFooter.kt @@ -0,0 +1,110 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioFooterKind +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import dev.chrisbanes.haze.rememberHazeState + +@Composable +internal fun AddToPortfolioBottomSheetFooter( + currentRoute: AddToPortfolioRoutes, + userPortfolioState: State?, + onBack: () -> Unit, + onAddFromUserPortfolioClick: (() -> Unit)?, +) { + when (currentRoute.uiSpec().footer) { + AddToPortfolioFooterKind.Cancel -> WithLocalHaze { + CancelFooterButton(onClick = onBack) + } + AddToPortfolioFooterKind.UserPortfolioAdd -> { + val state = userPortfolioState?.value ?: return + val onClick = onAddFromUserPortfolioClick ?: return + WithLocalHaze { + UserPortfolioAddFooter( + isEnabled = state.isAddEnabled, + onClick = onClick, + ) + } + } + AddToPortfolioFooterKind.None -> Unit + } +} + +@Composable +private fun WithLocalHaze(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalHazeState provides rememberHazeState(), content = content) +} + +@Composable +private fun CancelFooterButton(onClick: () -> Unit) { + SecondaryTangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x4), + onClick = onClick, + text = resourceReference(R.string.common_cancel), + size = TangemButtonSize.X12, + shape = TangemButtonShape.Rounded, + ) +} + +@Composable +private fun UserPortfolioAddFooter(isEnabled: Boolean, onClick: () -> Unit) { + TangemRowContainer( + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x6, + vertical = TangemTheme.dimens2.x5, + ), + ) { + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = stringResourceSafe(R.string.common_add_token), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = stringResourceSafe(R.string.markets_token_add_subtitle), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + SecondaryTangemButton( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x2), + onClick = onClick, + text = resourceReference(R.string.common_add), + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + isEnabled = isEnabled, + ) + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetSwitch.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetSwitch.kt new file mode 100644 index 0000000000..6cb218fbc1 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetSwitch.kt @@ -0,0 +1,34 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM + +@Composable +internal fun AddToPortfolioBottomSheetSwitch( + childStack: State>, + onBack: () -> Unit, + onDismiss: () -> Unit, + userPortfolioState: State? = null, + onAddFromUserPortfolioClick: (() -> Unit)? = null, +) { + if (LocalRedesignEnabled.current) { + AddToPortfolioBottomSheetV2( + childStack = childStack, + onBack = onBack, + onDismiss = onDismiss, + userPortfolioState = userPortfolioState, + onAddFromUserPortfolioClick = onAddFromUserPortfolioClick, + ) + } else { + AddToPortfolioBottomSheet( + childStack = childStack, + onBack = onBack, + onDismiss = onDismiss, + ) + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetV2.kt new file mode 100644 index 0000000000..866fc6456c --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetV2.kt @@ -0,0 +1,126 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.components.bottomsheets.* +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM + +@Composable +internal fun AddToPortfolioBottomSheetV2( + childStack: State>, + onBack: () -> Unit, + onDismiss: () -> Unit, + userPortfolioState: State? = null, + onAddFromUserPortfolioClick: (() -> Unit)? = null, +) { + val stack by childStack + val contentStack = remember { mutableStateOf(stack) } + val isNotEmpty = stack.active.configuration != AddToPortfolioRoutes.Empty + if (isNotEmpty) { + contentStack.value = stack + } + + TangemBottomSheet( + onBack = onBack, + config = TangemBottomSheetConfig( + isShown = isNotEmpty, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors2.surface.level2, + title = { + AddToPortfolioBottomSheetTitle( + stack = stack, + onCloseClick = onDismiss, + ) + }, + content = { + AnimatedContent(targetState = contentStack.value, label = "Content Animation") { animatedStack -> + AddToPortfolioRouteContent(animatedStack = animatedStack) + } + }, + footer = { + AnimatedContent( + targetState = contentStack.value.active.configuration, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "Footer Animation", + ) { route -> + AddToPortfolioBottomSheetFooter( + currentRoute = route, + userPortfolioState = userPortfolioState, + onBack = onBack, + onAddFromUserPortfolioClick = onAddFromUserPortfolioClick, + ) + } + }, + ) +} + +@Composable +private fun AddToPortfolioRouteContent(animatedStack: ChildStack) { + val spec = animatedStack.active.configuration.uiSpec() + val baseModifier = if (spec.shouldApplyHorizontalPadding) { + Modifier.padding(horizontal = TangemTheme.dimens2.x4) + } else { + Modifier + } + if (spec.isScrollable) { + val bottomInset = LocalTangemBottomSheetContentBottomInset.current + val scrollBottomReserve = if (bottomInset > 0.dp) bottomInset else TangemTheme.dimens2.x4 + Column(modifier = baseModifier.verticalScroll(rememberScrollState())) { + animatedStack.active.instance.Content(modifier = Modifier) + Spacer(modifier = Modifier.height(scrollBottomReserve)) + } + } else { + animatedStack.active.instance.Content(modifier = baseModifier) + } +} + +@Composable +private fun AddToPortfolioBottomSheetTitle( + stack: ChildStack, + onCloseClick: () -> Unit, +) { + TangemTopBar( + title = stack.active.configuration.uiSpec().title, + type = TangemTopBarType.BottomSheet, + endContent = { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier + .size(TangemTheme.dimens2.x11) + .background( + color = TangemTheme.colors2.button.backgroundSecondary, + shape = CircleShape, + ) + .clickableSingle(onClick = onCloseClick) + .padding(TangemTheme.dimens2.x2), + ) + }, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt similarity index 67% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt index b762aa9727..0530a497f3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt @@ -1,18 +1,20 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.addtoken.AddTokenContent +import com.tangem.common.ui.addtoken.AddTokenContentV2 import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,10 +31,17 @@ internal class AddTokenComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val state = model.uiState.collectAsStateWithLifecycle() val um = state.value ?: return - AddTokenContent( - modifier = modifier, - state = um, - ) + if (LocalRedesignEnabled.current) { + AddTokenContentV2( + modifier = modifier, + state = um, + ) + } else { + AddTokenContent( + modifier = modifier, + state = um, + ) + } } data class Params( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ChooseNetworkComponent.kt similarity index 79% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ChooseNetworkComponent.kt index 71c5a6dc55..95dab8e47a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ChooseNetworkComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -9,9 +9,9 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.ChooseNetworkContent +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt similarity index 57% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index 8f7dfb79a1..02e43ad3eb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -1,6 +1,7 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.backStack @@ -11,14 +12,16 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +@Suppress("LongParameterList") internal class DefaultAddToPortfolioComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: AddToPortfolioComponent.Params, @@ -26,6 +29,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( addTokenComponentFactory: AddTokenComponent.Factory, tokenActionsComponentFactory: TokenActionsComponent.Factory, private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory, + private val userPortfolioComponentFactory: UserPortfolioComponent.Factory, ) : AppComponentContext by context, AddToPortfolioComponent { private val model: AddToPortfolioModel = getOrCreateModel(params) @@ -33,29 +37,33 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create( context = child("portfolioSelectorComponent"), params = PortfolioSelectorComponent.Params( - portfolioFetcher = model.portfolioFetcher, + portfolioFetcher = model.addToPortfolioManager.portfolioFetcher, controller = model.portfolioSelectorController, ), ) - private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( - context = child("addTokenComponent"), - params = AddTokenComponent.Params( - eventBuilder = model.eventBuilder, - callbacks = model, - selectedPortfolio = model.selectedPortfolio, - selectedNetwork = model.selectedNetwork, - ), - ) + private val addTokenComponent: AddTokenComponent by lazy { + addTokenComponentFactory.create( + context = child("addTokenComponent"), + params = AddTokenComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + selectedPortfolio = model.selectedPortfolio, + selectedNetwork = model.selectedNetwork, + ), + ) + } - private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create( - context = child("tokenActionsComponent"), - params = TokenActionsComponent.Params( - eventBuilder = model.eventBuilder, - callbacks = model, - data = model.tokenActionsData, - ), - ) + private val tokenActionsComponent: TokenActionsComponent by lazy { + tokenActionsComponentFactory.create( + context = child("tokenActionsComponent"), + params = TokenActionsComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + data = model.tokenActionsData, + ), + ) + } private val childStack = childStack( key = "addToPortfolioStack", @@ -71,15 +79,19 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( } override fun dismiss() { - params.callback.onDismiss() + model.addToPortfolioManager.onDismiss() } @Composable override fun BottomSheet() { - AddToPortfolioBottomSheet( + val userPortfolioState = model.userPortfolioStateController.uiState + .collectAsStateWithLifecycle() + AddToPortfolioBottomSheetSwitch( childStack = childStack.subscribeAsState(), onBack = ::onBack, onDismiss = ::dismiss, + userPortfolioState = userPortfolioState, + onAddFromUserPortfolioClick = model::onContinueFromUserPortfolio, ) } @@ -91,6 +103,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent AddToPortfolioRoutes.TokenActions -> tokenActionsComponent AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY + AddToPortfolioRoutes.UserPortfolio -> createUserPortfolioComponent(componentContext) is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create( context = childByContext(componentContext), params = ChooseNetworkComponent.Params( @@ -100,6 +113,16 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( ) } + private fun createUserPortfolioComponent(componentContext: ComponentContext): ComposableContentComponent { + return userPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = UserPortfolioComponent.Params( + uiState = model.userPortfolioStateController.uiState, + callbacks = model, + ), + ) + } + @AssistedFactory interface Factory : AddToPortfolioComponent.Factory { override fun create( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt similarity index 75% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt index 9498c4041f..30cd1227fc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -8,17 +8,19 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.ui.markets.action.CryptoCurrencyData import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.domain.models.TokenReceiveConfig -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.TokenActionsContent -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContentV2 import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -44,10 +46,17 @@ internal class TokenActionsComponent @AssistedInject constructor( val state = model.uiState.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() val tokenActionsUM = state.value ?: return - TokenActionsContent( - modifier = modifier, - state = tokenActionsUM, - ) + if (LocalRedesignEnabled.current) { + TokenActionsContentV2( + modifier = modifier, + state = tokenActionsUM, + ) + } else { + TokenActionsContent( + modifier = modifier, + state = tokenActionsUM, + ) + } bottomSheet.child?.instance?.BottomSheet() } @@ -64,7 +73,7 @@ internal class TokenActionsComponent @AssistedInject constructor( data class Params( val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, - val data: Flow, + val data: Flow, val callbacks: Callbacks, ) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/EarnAnalyticsEvent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/EarnAnalyticsEvent.kt new file mode 100644 index 0000000000..5980f4c26c --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/EarnAnalyticsEvent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +// todo swap unify with PortfolioAnalyticsEvent, AddToPortfolioFlow +internal sealed class EarnAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Earn", event = event, params = params) { + + data class AddTokenScreenOpened( + private val tokenSymbol: String, + private val blockchain: String, + private val source: String, + ) : EarnAnalyticsEvent( + event = "Add Token Screen Opened", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to tokenSymbol, + AnalyticsParam.BLOCKCHAIN to blockchain, + AnalyticsParam.SOURCE to source, + ), + ) + + data class TokenAdded( + private val tokenSymbol: String, + private val blockchain: String, + ) : EarnAnalyticsEvent( + event = "Token Added", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to tokenSymbol, + AnalyticsParam.BLOCKCHAIN to blockchain, + ), + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt new file mode 100644 index 0000000000..925cf4831b --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,104 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics + +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +internal class PortfolioAnalyticsEvent( + event: String, + params: Map = emptyMap(), + category: String, +) : AnalyticsEvent(category = category, event = event, params = params) { + + data class EventBuilder( + val tokenSymbol: String, + val source: String?, + val category: String, + ) { + + fun popupToChooseAccount() = PortfolioAnalyticsEvent( + event = "Choose Account Opened", + category = category, + params = buildMap { + if (source != null) put(AnalyticsParam.SOURCE, source) + }, + ) + + fun popupToConfirm(blockchain: String) = PortfolioAnalyticsEvent( + event = "Add Token Screen Opened", + category = category, + params = buildMap { + put(AnalyticsParam.TOKEN_PARAM, tokenSymbol) + put(AnalyticsParam.BLOCKCHAIN, blockchain) + if (source != null) put(AnalyticsParam.SOURCE, source) + }, + ) + + fun addToNotMainAccount() = PortfolioAnalyticsEvent( + event = "Button - Add To Account", + category = category, + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun addButtonClick() = PortfolioAnalyticsEvent( + event = "Button - Add Token", + category = category, + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent( + event = "Wallet Selected", + category = category, + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( + event = "Token Network Selected", + category = category, + params = buildMap { + put("Count", blockchainNames.size.toString()) + put("Token", tokenSymbol) + put("blockchain", blockchainNames.joinToString(separator = ", ")) + if (source != null) put("Source", source) + }, + ) + + fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent( + event = "Token Added", + category = category, + params = buildMap { + put(AnalyticsParam.TOKEN_PARAM, tokenSymbol) + put(AnalyticsParam.BLOCKCHAIN, blockchainName) + if (source != null) put("Source", source) + }, + ) + + fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy" + TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive" + TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange" + TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" + else -> "error" + }, + category = category, + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun getTokenLater() = PortfolioAnalyticsEvent( + event = "Popup Get token - Button Later", + category = category, + params = buildMap { + if (source != null) put("Source", source) + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt similarity index 89% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt index 946ab12577..ed829bd5c3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt @@ -1,10 +1,10 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.converter +package com.tangem.features.commonfeatures.impl.addtoportfolio.converter import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus @@ -12,10 +12,10 @@ import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddAccount -import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData -import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddWallet +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet import javax.inject.Inject internal class AvailableToAddDataConverter @Inject constructor( @@ -27,7 +27,7 @@ internal class AvailableToAddDataConverter @Inject constructor( suspend fun convert( balances: Map, availableNetworks: Set, - marketParams: TokenMarketParams, + marketParams: RawMarketToken, ): AvailableToAddData { suspend fun AccountStatus.CryptoPortfolio.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { val currencies = availableNetworks @@ -103,7 +103,7 @@ internal class AvailableToAddDataConverter @Inject constructor( private suspend fun createCryptoCurrency( userWallet: UserWallet, network: TokenMarketInfo.Network, - marketParams: TokenMarketParams, + marketParams: RawMarketToken, account: Account.CryptoPortfolio, ): CryptoCurrency? { return getTokenMarketCryptoCurrency( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt similarity index 65% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt index e4be2f3924..f5666caa2c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt @@ -1,15 +1,15 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.converter +import com.tangem.common.ui.extensions.greyedOutIconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.components.rows.model.BlockchainRowUM -import com.tangem.core.ui.extensions.getActiveIconRes -import com.tangem.core.ui.extensions.getGreyedOutIconRes import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketInfo.Network +import com.tangem.domain.models.network.Network import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.converter.Converter /** - * Converter from [TokenMarketInfo.Network] to [BlockchainRowUM] + * Converter from [com.tangem.domain.markets.TokenMarketInfo.Network] to [com.tangem.core.ui.components.rows.model.BlockchainRowUM] * * @property alreadyAddedNetworks set of already added networks * @@ -17,9 +17,9 @@ import com.tangem.utils.converter.Converter */ internal class BlockchainRowUMConverter( private val alreadyAddedNetworks: Set, -) : Converter, BlockchainRowUM> { +) : Converter, BlockchainRowUM> { - override fun convert(value: Pair): BlockchainRowUM { + override fun convert(value: Pair): BlockchainRowUM { val (network, isSelected) = value val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId) @@ -28,6 +28,7 @@ internal class BlockchainRowUMConverter( val isMainNetwork = network.contractAddress == null val isEnabled = !alreadyAddedNetworks.contains(network.networkId) + val networkRawId = Network.RawID(value = network.networkId) return BlockchainRowUM( id = network.networkId, @@ -35,12 +36,12 @@ internal class BlockchainRowUMConverter( type = getNetworkType(network, blockchainInfo), iconResId = if (isEnabled) { if (isSelected) { - getActiveIconRes(blockchainInfo.blockchainId) + networkRawId.iconResId } else { - getGreyedOutIconRes(blockchainInfo.blockchainId) + networkRawId.greyedOutIconResId } } else { - getGreyedOutIconRes(blockchainInfo.blockchainId) + networkRawId.greyedOutIconResId }, isMainNetwork = isMainNetwork, isSelected = isSelected, @@ -48,7 +49,10 @@ internal class BlockchainRowUMConverter( ) } - private fun getNetworkType(network: Network, blockchainInfo: BlockchainUtils.BlockchainInfo): String { + private fun getNetworkType( + network: TokenMarketInfo.Network, + blockchainInfo: BlockchainUtils.BlockchainInfo, + ): String { val isMainNetwork = network.contractAddress == null return when { BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt new file mode 100644 index 0000000000..020655ca65 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt @@ -0,0 +1,28 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.di + +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.DefaultUserPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AddToPortfolioComponentModule { + + @Binds + fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory + + @Binds + fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory + + @Binds + fun bindUserPortfolioComponentFactory( + factory: DefaultUserPortfolioComponent.Factory, + ): UserPortfolioComponent.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt similarity index 58% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt index 60b3f51d91..699b4df576 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt @@ -1,8 +1,12 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.di +package com.tangem.features.commonfeatures.impl.addtoportfolio.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.* +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -23,11 +27,6 @@ internal interface AddToPortfolioModelModule { @ClassKey(AddToPortfolioModel::class) fun addToPortfolioModel(model: AddToPortfolioModel): Model - @Binds - @IntoMap - @ClassKey(AddToPortfolioPreselectedDataModel::class) - fun addToPortfolioPreselectedDataModel(model: AddToPortfolioPreselectedDataModel): Model - @Binds @IntoMap @ClassKey(TokenActionsModel::class) @@ -37,4 +36,9 @@ internal interface AddToPortfolioModelModule { @IntoMap @ClassKey(ChooseNetworkModel::class) fun chooseNetworkModel(model: ChooseNetworkModel): Model + + @Binds + @IntoMap + @ClassKey(UserPortfolioModel::class) + fun userPortfolioModel(model: UserPortfolioModel): Model } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt new file mode 100644 index 0000000000..d266107e75 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt @@ -0,0 +1,152 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import arrow.core.getOrElse +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet +import javax.inject.Inject + +internal class AddToPortfolioInitialSelectionResolver @Inject constructor( + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, +) { + + suspend fun resolve( + availableToAddData: AvailableToAddData, + orderedNetworks: List, + selectedWallet: UserWallet?, + tokenParams: RawMarketToken, + accountToAdd: AvailableToAddAccount? = null, + preferredNetwork: TokenMarketInfo.Network? = null, + ): InitialSelection? { + if (availableToAddData.availableToAddWallets.isEmpty()) return null + val fallbackNetwork = orderedNetworks.firstOrNull() ?: return null + + val walletOrder = orderedWallets(availableToAddData, selectedWallet) + + if (accountToAdd != null) { + val ownerWallet = walletOrder.firstOrNull { entry -> + entry.availableToAddAccounts.values.any { it === accountToAdd } + } ?: walletOrder.first() + val network = pickNetworkForExplicitAccount( + userWallet = ownerWallet.userWallet, + account = accountToAdd, + orderedNetworks = orderedNetworks, + tokenParams = tokenParams, + preferredNetwork = preferredNetwork, + ) + return InitialSelection(userWallet = ownerWallet.userWallet, account = accountToAdd, network = network) + } + + for (walletEntry in walletOrder) { + val account = pickAvailableAccount(walletEntry) ?: continue + val network = pickAddableNetwork( + userWallet = walletEntry.userWallet, + account = account, + orderedNetworks = orderedNetworks, + tokenParams = tokenParams, + ) ?: continue + return InitialSelection(walletEntry.userWallet, account, network) + } + + val fallbackWallet = walletOrder.first() + val fallbackAccount = pickFallbackAccount(fallbackWallet) ?: return null + return InitialSelection(fallbackWallet.userWallet, fallbackAccount, fallbackNetwork) + } + + private fun orderedWallets(data: AvailableToAddData, selectedWallet: UserWallet?): List { + val preferred = selectedWallet?.walletId?.let { data.availableToAddWallets[it] } + return buildList { + preferred?.let(::add) + data.availableToAddWallets.values.forEach { entry -> + if (entry !== preferred) add(entry) + } + } + } + + private fun pickAvailableAccount(walletEntry: AvailableToAddWallet): AvailableToAddAccount? { + val mainId = AccountId.forMainCryptoPortfolio(walletEntry.userWallet.walletId) + return walletEntry.availableToAddAccounts[mainId]?.takeIf { it.isAvailableToAdd } + ?: walletEntry.availableToAddAccounts.values.firstOrNull { it.isAvailableToAdd } + } + + private fun pickFallbackAccount(walletEntry: AvailableToAddWallet): AvailableToAddAccount? { + val mainId = AccountId.forMainCryptoPortfolio(walletEntry.userWallet.walletId) + return walletEntry.availableToAddAccounts[mainId] + ?: walletEntry.availableToAddAccounts.values.firstOrNull() + } + + private suspend fun pickNetworkForExplicitAccount( + userWallet: UserWallet, + account: AvailableToAddAccount, + orderedNetworks: List, + tokenParams: RawMarketToken, + preferredNetwork: TokenMarketInfo.Network?, + ): TokenMarketInfo.Network { + if (preferredNetwork != null) { + val candidate = account.availableToAddNetworks + .firstOrNull { it.networkId == preferredNetwork.networkId } + if ( + candidate != null && hasDerivationFor( + userWallet = userWallet, + account = account, + network = candidate, + tokenParams = tokenParams, + ) + ) { + return candidate + } + } + return pickAddableNetwork( + userWallet = userWallet, + account = account, + orderedNetworks = orderedNetworks, + tokenParams = tokenParams, + ) ?: orderedNetworks.first() + } + + private suspend fun pickAddableNetwork( + userWallet: UserWallet, + account: AvailableToAddAccount, + orderedNetworks: List, + tokenParams: RawMarketToken, + ): TokenMarketInfo.Network? { + val availableOrdered = orderedNetworks.filter { candidate -> + account.availableToAddNetworks.any { it.networkId == candidate.networkId } + } + if (availableOrdered.isEmpty()) return null + + val withDerivation = availableOrdered.firstOrNull { network -> + hasDerivationFor(userWallet = userWallet, account = account, network = network, tokenParams = tokenParams) + } + return withDerivation ?: availableOrdered.first() + } + + private suspend fun hasDerivationFor( + userWallet: UserWallet, + account: AvailableToAddAccount, + network: TokenMarketInfo.Network, + tokenParams: RawMarketToken, + ): Boolean { + val derivationIndex = account.account.account.derivationIndex + val currency = getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = tokenParams, + network = network, + accountIndex = derivationIndex, + ) ?: return false + return networkHasDerivationUseCase(userWallet, currency.network).getOrElse { false } + } + + data class InitialSelection( + val userWallet: UserWallet, + val account: AvailableToAddAccount, + val network: TokenMarketInfo.Network, + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt new file mode 100644 index 0000000000..0aaee52e02 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -0,0 +1,566 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.popToFirst +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.QuickActionsConverter.toQuickActions +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.* +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state.UserPortfolioStateController +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val TOKEN_ACTIONS_DELAY = 500L + +@ModelScoped +@Suppress("LongParameterList", "LargeClass") +internal class AddToPortfolioModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + val portfolioSelectorController: PortfolioSelectorController, + private val designFeatureToggles: DesignFeatureToggles, + private val callbackDelegate: AddToPortfolioCallbackDelegate, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val messageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val selectionResolver: AddToPortfolioInitialSelectionResolver, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + userPortfolioStateControllerFactory: UserPortfolioStateController.Factory, +) : Model(), + ChooseNetworkComponent.Callbacks by callbackDelegate, + TokenActionsComponent.Callbacks by callbackDelegate, + AddTokenComponent.Callbacks by callbackDelegate, + UserPortfolioComponent.Callbacks by callbackDelegate { + + private val params = paramsContainer.require() + val navigation = StackNavigation() + var currentStack = listOf(AddToPortfolioRoutes.Empty) + + /* Flows that hold state and provide it to child models */ + val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() + val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() + val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() + + val addToPortfolioManager: AddToPortfolioManager = params.addToPortfolioManager + + val paramsSnapshot: AddToPortfolioManager.Params by lazy { + addToPortfolioManager.paramsFlow.replayCache.first() + } + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder by lazy { + val tokenMarketParams = paramsSnapshot.token + PortfolioAnalyticsEvent.EventBuilder( + tokenSymbol = tokenMarketParams.symbol, + source = addToPortfolioManager.analyticsParams.source, + category = addToPortfolioManager.analyticsParams.category, + ) + } + + val userPortfolioStateController = userPortfolioStateControllerFactory.create( + modelScope = modelScope, + onTokenSelected = { result -> addToPortfolioManager.onAddedTokenClick(result) }, + ) + + private val globalSelectedWallet: UserWallet? + get() = getSelectedWalletSyncUseCase().getOrNull() + .takeIf { it?.isMultiCurrency == true } + + init { + navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } + startRedesignAddToPortfolioFlow() + } + + private fun replayMutableSharedFlow() = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private fun lineNavigationFlowToAddTokenScreen( + isAccountMode: Boolean, + data: AvailableToAddData, + firstSelectedPortfolioFlow: Flow, + ): Flow { + val isSinglePortfolio = data.isSinglePortfolio + if (isSinglePortfolio) { + val accountId = data.availableToAddWallets.values.first() + .availableToAddAccounts.values.first() + .account.account.accountId + // force select a portfolio, triggers [selectedPortfolio] + portfolioSelectorController.selectAccount(accountId) + } else { + logAccountSelector(isAccountMode) + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) + } + + return firstSelectedPortfolioFlow.onEach { portfolio -> + val isAvailableToAdd = portfolio.account.isAvailableToAdd + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + when { + // force select an added network, not allowed to add + // but its call AddToPortfolioManager.onAddedTokenClick callback + !isAvailableToAdd -> { + val singleNetwork = portfolio.account.addedMarketNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // force select a network, triggers [selectedNetwork] + isSingleAvailableNetwork -> { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // it's important to control root screen, UI depends on it(close/arrow icon) + isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) + else -> navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + } + + @Suppress("LongMethod") + private fun startRedesignAddToPortfolioFlow() { + channelFlow { + fun finishSuccessFlow(result: AddToPortfolioManager.Result) { + addToPortfolioManager.onSuccessAdded(result) + channel.close() + } + fun finishOnAddedTokenClick(result: AddToPortfolioManager.Result) { + addToPortfolioManager.onAddedTokenClick(result) + channel.close() + } + + fun finishDismissFlow() { + addToPortfolioManager.onDismiss() + channel.close() + } + + val tokenMarketParams = paramsSnapshot.token + + val launchMode = paramsSnapshot.launchMode + val isAccountMode = portfolioSelectorController.isAccountModeSync() + val initialData: AvailableToAddData = addToPortfolioManager.state + .filterIsInstance() + .map { it.availableToAddData } + .first() + + setupPortfolioSelector(initialData, launchMode) + + val shouldShowUserPortfolio = designFeatureToggles.isRedesignEnabled && + launchMode is AddToPortfolioManager.LaunchMode.ViaUserPortfolio && + initialData.hasAnyAddedCurrency(tokenMarketParams.id) + + if (shouldShowUserPortfolio) { + // suspend, must prepare UM before navigate to UserPortfolio + userPortfolioStateController.updateAndWaitNotNullState( + allAvailableData = initialData, + rawCurrencyId = tokenMarketParams.id, + ) + navigation.replaceAll(AddToPortfolioRoutes.UserPortfolio) + callbackDelegate.onContinueFromUserPortfolio.receiveAsFlow().first() + } + + val initialSelection: AddToPortfolioInitialSelectionResolver.InitialSelection? = when (launchMode) { + AddToPortfolioManager.LaunchMode.Preselected -> null + is AddToPortfolioManager.LaunchMode.ViaUserPortfolio, + AddToPortfolioManager.LaunchMode.DirectAdd, + -> if (designFeatureToggles.isRedesignEnabled) { + getInitialSelection(initialData) + } else { + null + } + } + + val firstSelectedPortfolioFlow: Flow = + setupPortfolioFlow(initialData).onEach { selectedPortfolio.emit(it) } + val firstSelectedNetworkFlow: Flow = + setupNetworkFlow(firstSelectedPortfolioFlow).onEach { selectedNetwork.emit(it) } + + // main flow that combine all require data + val allRequireForAdd = combine( + flow = firstSelectedNetworkFlow, + flow2 = firstSelectedPortfolioFlow, + transform = { a, b -> a to b }, + ) + + var firstPartOfNavigation: Job? = null + + if (initialSelection != null) { + launch { + portfolioSelectorController.selectAccount(initialSelection.account.account.accountId) + callbackDelegate.onNetworkSelected.send(initialSelection.network) + } + } else { + firstPartOfNavigation = lineNavigationFlowToAddTokenScreen( + isAccountMode = isAccountMode, + data = initialData, + firstSelectedPortfolioFlow = firstSelectedPortfolioFlow, + ).launchIn(this) + } + + // suspend until all required data is selected + val (firstSelectedNetwork, firstSelectedPortfolio) = allRequireForAdd.first() + // line navigation to AddToken screen is finished; cancel the job if exists + firstPartOfNavigation?.cancel() + + val alreadyAddedToken = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = firstSelectedPortfolio.userWallet.walletId, + currency = firstSelectedNetwork.cryptoCurrency, + ).getOrNull() + + if (alreadyAddedToken != null) { + val result = AddToPortfolioManager.Result( + wallet = firstSelectedPortfolio.userWallet, + account = firstSelectedPortfolio.account.account, + addedCurrency = alreadyAddedToken.status, + ) + finishOnAddedTokenClick(result) + return@channelFlow + } + + val selectedNetworkName = firstSelectedNetwork.cryptoCurrency.network.name + analyticsEventHandler.send(event = eventBuilder.popupToConfirm(selectedNetworkName)) + navigation.replaceAll(AddToPortfolioRoutes.AddToken) + + var middleNavigationJob: Job? = null + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changeNetworkNavigationFlow() + .launchIn(this) + val route = routeToNetworkSelector(selectedPortfolio.first()) + navigation.pushNew(route) + } + .launchIn(this) + + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = if (designFeatureToggles.isRedesignEnabled) { + changePortfolioNavigationNewFlow( + data = initialData, + orderedNetworks = paramsSnapshot.networks, + tokenParams = tokenMarketParams, + ) + } else { + changePortfolioNavigationFlow(initialData) + }.launchIn(this) + logAccountSelector(isAccountMode) + navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) + } + .launchIn(this) + + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + middleNavigationJob?.cancel() + val selectedPortfolioSnapshot = selectedPortfolio.first() + val result = AddToPortfolioManager.Result( + wallet = selectedPortfolioSnapshot.userWallet, + account = selectedPortfolioSnapshot.account.account, + addedCurrency = addedToken, + ) + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + if (addToPortfolioManager.settings.shouldSkipTokenActionsScreen) { + finishSuccessFlow(result) + return@channelFlow + } + + setupTokenActionsFlow(selectedPortfolioSnapshot, addedToken) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishSuccessFlow(result) } + .launchIn(this) + + callbackDelegate.onLaterClick.receiveAsFlow().first() + analyticsEventHandler.send(eventBuilder.getTokenLater()) + finishSuccessFlow(result) + } + .catch { throwable -> + TangemLogger.e("Error", throwable) + addToPortfolioManager.onDismiss() + } + .launchIn(modelScope) + } + + private suspend fun getInitialSelection( + initialData: AvailableToAddData, + ): AddToPortfolioInitialSelectionResolver.InitialSelection? { + return selectionResolver.resolve( + availableToAddData = initialData, + orderedNetworks = paramsSnapshot.networks, + selectedWallet = globalSelectedWallet, + tokenParams = paramsSnapshot.token, + ) + } + + private fun logAccountSelector(isAccountMode: Boolean) { + if (isAccountMode) { + analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) + } + } + + private fun changeNetworkNavigationFlow(): Flow { + return setupNetworkFlow(selectedPortfolio) + .onEach { newNetwork -> + selectedNetwork.emit(newNetwork) + navigation.popToFirst() + } + } + + private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow = flow { + val selectedPortfolioValue = selectedPortfolio.first() + val selectedAccount = selectedPortfolioValue.account.account.account.accountId + portfolioSelectorController.selectAccount(selectedAccount) + val changedPortfolio = setupPortfolioFlow(data) + .drop(1) + .onEach { portfolio -> + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + if (isSingleAvailableNetwork) { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } else { + navigation.pushNew(routeToNetworkSelector(portfolio)) + } + } + val changedNetwork = setupNetworkFlow(changedPortfolio) + combine( + flow = changedPortfolio, + flow2 = changedNetwork, + transform = { newPortfolio, newNetwork -> + selectedPortfolio.tryEmit(newPortfolio) + selectedNetwork.tryEmit(newNetwork) + navigation.popToFirst() + }, + ).collect { emit(it) } + } + + private fun changePortfolioNavigationNewFlow( + data: AvailableToAddData, + orderedNetworks: List, + tokenParams: RawMarketToken, + ): Flow { + return setupPortfolioFlow(data) + // drop first selected portfolio or any selected before + .drop(1) + .map { newPortfolio -> + val rebuiltSelectedNetwork = selectionResolver.resolve( + availableToAddData = data, + orderedNetworks = orderedNetworks, + selectedWallet = globalSelectedWallet, + tokenParams = tokenParams, + accountToAdd = newPortfolio.account, + preferredNetwork = selectedNetwork.first().selectedNetwork, + )?.toSelectedNetwork() + + if (rebuiltSelectedNetwork != null) { + this.selectedNetwork.tryEmit(rebuiltSelectedNetwork) + } + selectedPortfolio.tryEmit(newPortfolio) + navigation.popToFirst() + } + } + + private fun setupTokenActionsFlow( + selectedPortfolio: SelectedPortfolio, + addedToken: CryptoCurrencyStatus, + ): Flow { + val timeFlow = channelFlow { + val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } + getCryptoCurrencyActionsUseCase( + currency = addedToken.currency, + accountId = selectedPortfolio.account.account.account.accountId, + ).onEach { state -> + val requestedQuickActions = toQuickActions(state.states, designFeatureToggles.isRedesignEnabled) + when { + requestedQuickActions.isNotEmpty() -> { + timerJob.cancel() + send(state) + } + // wait any requestedQuickActions while timer active + timerJob.isActive -> Unit + else -> close() + } + }.collect() + } + return timeFlow.map { actionsState -> + CryptoCurrencyData( + userWallet = selectedPortfolio.userWallet, + status = actionsState.cryptoCurrencyStatus, + actions = actionsState.states, + isAccountMode = selectedPortfolio.isAccountMode, + account = selectedPortfolio.account.account, + ) + } + } + + private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( + flow = portfolioSelectorController.isAccountMode, + flow2 = portfolioSelectorController.selectedAccount, + transform = { isAccountMode, selectedAccountId -> + selectedAccountId ?: return@combine null + val availableToAddWallets = + data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null + val availableToAddAccount = + availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null + if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) + SelectedPortfolio( + isAccountMode = isAccountMode, + userWallet = availableToAddWallets.userWallet, + account = availableToAddAccount, + isAvailableMorePortfolio = !data.isSinglePortfolio, + ) + }, + ) + .filterNotNull() + + private fun setupNetworkFlow(selectedPortfolioFlow: Flow): Flow = combine( + flow = selectedPortfolioFlow, + flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), + transform = transform@{ selectedPortfolio, selectedNetwork -> + SelectedNetwork( + cryptoCurrency = createCryptoCurrency( + userWallet = selectedPortfolio.userWallet, + network = selectedNetwork, + account = selectedPortfolio.account, + ) ?: return@transform null, + selectedNetwork = selectedNetwork, + isAvailableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, + ) + }, + ) + .filterNotNull() + + private suspend fun createCryptoCurrency( + userWallet: UserWallet, + network: TokenMarketInfo.Network, + account: AvailableToAddAccount, + ): CryptoCurrency? { + val accountIndex = account.account.account.derivationIndex + return getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = addToPortfolioManager.token(), + network = network, + accountIndex = accountIndex, + ) + } + + private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector { + return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) + } + + private fun setupPortfolioSelector(data: AvailableToAddData, launchMode: AddToPortfolioManager.LaunchMode) { + portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> + when (launchMode) { + AddToPortfolioManager.LaunchMode.Preselected -> return@isEnabled true + AddToPortfolioManager.LaunchMode.DirectAdd, + is AddToPortfolioManager.LaunchMode.ViaUserPortfolio, + -> { + val availableWallet = data.availableToAddWallets[userWallet.walletId] + ?: return@isEnabled false + val isAvailableAccount = availableWallet + .availableToAddAccounts[accountStatus.account.accountId] + ?.isAvailableToAdd == true + return@isEnabled isAvailableAccount + } + } + } + } + + private suspend fun AddToPortfolioInitialSelectionResolver.InitialSelection.toSelectedNetwork(): SelectedNetwork? { + val crypto = createCryptoCurrency( + userWallet = userWallet, + network = network, + account = account, + ) ?: return null + return SelectedNetwork( + cryptoCurrency = crypto, + selectedNetwork = network, + isAvailableMoreNetwork = !account.isSingleNetwork, + ) + } +} + +@ModelScoped +internal class AddToPortfolioCallbackDelegate @Inject constructor() : + ChooseNetworkComponent.Callbacks, + TokenActionsComponent.Callbacks, + AddTokenComponent.Callbacks, + UserPortfolioComponent.Callbacks { + + val onNetworkSelected = Channel() + val onLaterClick = Channel() + val onChangeNetworkClick = Channel() + val onChangePortfolioClick = Channel() + val onTokenAdded = Channel() + val onContinueFromUserPortfolio = Channel() + + override fun onNetworkSelected(network: TokenMarketInfo.Network) { + onNetworkSelected.trySend(network) + } + + override fun onLaterClick() { + onLaterClick.trySend(Unit) + } + + override fun onChangeNetworkClick() { + onChangeNetworkClick.trySend(Unit) + } + + override fun onChangePortfolioClick() { + onChangePortfolioClick.trySend(Unit) + } + + override fun onTokenAdded(status: CryptoCurrencyStatus) { + onTokenAdded.trySend(status) + } + + override fun onContinueFromUserPortfolio() { + onContinueFromUserPortfolio.trySend(Unit) + } +} + +private fun AvailableToAddData.hasAnyAddedCurrency(rawCurrencyId: CryptoCurrency.RawID): Boolean { + return availableToAddWallets.values.any { wallet -> + wallet.accounts.filterCryptoPortfolio().any { accountStatus -> + accountStatus.tokenList.flattenCurrencies().any { status -> + val id = status.currency.id.rawCurrencyId ?: return@any false + getTokenIdIfL2Network(id.value) == rawCurrencyId.value + } + } + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt new file mode 100644 index 0000000000..bd256958e8 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt @@ -0,0 +1,57 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.commonfeatures.impl.R + +internal data class AddToPortfolioRouteUiSpec( + val title: TextReference, + val isScrollable: Boolean, + val shouldApplyHorizontalPadding: Boolean, + val footer: AddToPortfolioFooterKind, +) + +internal enum class AddToPortfolioFooterKind { + None, + Cancel, + UserPortfolioAdd, +} + +internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (this) { + AddToPortfolioRoutes.AddToken -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_add_token), + isScrollable = true, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.None, + ) + is AddToPortfolioRoutes.NetworkSelector -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_add_token), + isScrollable = true, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.Cancel, + ) + AddToPortfolioRoutes.PortfolioSelector -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_add_token), + isScrollable = false, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.Cancel, + ) + AddToPortfolioRoutes.UserPortfolio -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.markets_portfolio_block_title), + isScrollable = false, + shouldApplyHorizontalPadding = false, + footer = AddToPortfolioFooterKind.UserPortfolioAdd, + ) + AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_get_token), + isScrollable = true, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.None, + ) + AddToPortfolioRoutes.Empty -> AddToPortfolioRouteUiSpec( + title = TextReference.EMPTY, + isScrollable = false, + shouldApplyHorizontalPadding = false, + footer = AddToPortfolioFooterKind.None, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt similarity index 74% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt index a8c2acb3aa..8c01391c47 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt @@ -1,8 +1,8 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import androidx.compose.runtime.Immutable import com.tangem.core.decompose.navigation.Route -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio import kotlinx.serialization.Serializable @Serializable @@ -23,6 +23,9 @@ internal sealed interface AddToPortfolioRoutes : Route { @Serializable data object AddToken : AddToPortfolioRoutes + @Serializable + data object UserPortfolio : AddToPortfolioRoutes + @Serializable data object TokenActions : AddToPortfolioRoutes } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt similarity index 84% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt index 2fb83f4031..7b7facbc0e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.tangem.common.ui.addtoken.AddTokenUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -12,11 +12,11 @@ import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -40,7 +40,6 @@ internal class AddTokenModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val analyticsEventBuilder = params.eventBuilder private val addTokenJob = JobHolder() val uiState: StateFlow @@ -86,10 +85,11 @@ internal class AddTokenModel @Inject constructor( uiState.value = um.toggleProgress(true) val blockchainNames = listOf(selectedNetwork.selectedNetwork) .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } + val analyticsEventBuilder = params.eventBuilder analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) analyticsEventHandler.send(analyticsEventBuilder.addButtonClick()) - manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + manageCryptoCurrenciesUseCase.invokeAndAwait(accountId = accountId, add = listOf(cryptoCurrency)) .onLeft { throwable -> processError(error = throwable) uiState.value = um.toggleProgress(false) @@ -100,18 +100,11 @@ internal class AddTokenModel @Inject constructor( userWalletId = accountId.userWalletId, currencyId = cryptoCurrency.id, network = cryptoCurrency.network, - ).firstOrNull() + ) + .firstOrNull() if (status == null) { processError(error = null) } else { - if (!account.isMainAccount) { - analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) - } - - analyticsEventHandler.send( - event = analyticsEventBuilder.tokenAdded(status.status.currency.network.name), - ) - params.callbacks.onTokenAdded(status.status) } uiState.value = um.toggleProgress(false) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt similarity index 88% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt index 2055a204ab..3356e8a39c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter @@ -7,17 +7,17 @@ import com.tangem.common.ui.account.toUM import com.tangem.common.ui.addtoken.AddTokenUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountStatus.* -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio +import com.tangem.features.commonfeatures.impl.R import javax.inject.Inject @ModelScoped diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/CheckCurrencyUnsupportedDelegate.kt similarity index 96% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/CheckCurrencyUnsupportedDelegate.kt index 8b32b2497b..2bc12e52e3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/CheckCurrencyUnsupportedDelegate.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import arrow.core.getOrElse import com.tangem.core.decompose.ui.UiMessageSender @@ -10,7 +10,7 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.R import com.tangem.utils.logging.TangemLogger import javax.inject.Inject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/ChooseNetworkModel.kt similarity index 85% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/ChooseNetworkModel.kt index 5dc3fc5a62..afc9170d74 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/ChooseNetworkModel.kt @@ -1,13 +1,13 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM -import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter +import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.BlockchainRowUMConverter +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt similarity index 63% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt index 65e1cf28d1..9619c92763 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt @@ -1,25 +1,24 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory -import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM -import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -28,6 +27,7 @@ import javax.inject.Inject internal class TokenActionsModel @Inject constructor( paramsContainer: ParamsContainer, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, tokenActionsIntentsFactory: TokenActionsHandler.Factory, override val dispatchers: CoroutineDispatcherProvider, private val uiBuilder: TokenActionsUiBuilder, @@ -51,19 +51,34 @@ internal class TokenActionsModel @Inject constructor( ) val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val uiState: StateFlow = params.data - .mapLatest { uiBuilder.build(it, tokenActionsHandler) } - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = null, - ) - private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) { + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = + combine( + params.data, + getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { cryptoCurrencyData, isBalanceHidden -> + cryptoCurrencyData to isBalanceHidden + } + .mapLatest { (cryptoCurrencyData, isBalanceHidden) -> + uiBuilder.build( + cryptoCurrencyData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + appCurrency = currentAppCurrency.value, + isBalanceHidden = isBalanceHidden, + ) + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + + private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch { val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action) analyticsEventHandler.send(event) val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive - if (!isReceive) return + if (!isReceive) return@launch modelScope.launch { val tokenConfig = receiveAddressesFactory.create( status = handledAction.cryptoCurrencyData.status, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt new file mode 100644 index 0000000000..b63304be34 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -0,0 +1,217 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getTotalCryptoAmount +import com.tangem.common.getTotalFiatAmount +import com.tangem.common.ui.account.* +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions +import com.tangem.common.ui.markets.action.TokenActionsHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.R +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition +import com.tangem.core.ui.ds.badge.TangemBadgeShape +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.badge.TangemBadgeUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM +import java.math.BigDecimal +import javax.inject.Inject + +@ModelScoped +internal class TokenActionsUiBuilder @Inject constructor( + paramsContainer: ParamsContainer, + private val designFeatureToggles: DesignFeatureToggles, +) { + private val params = paramsContainer.require() + + fun build( + cryptoCurrencyData: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + appCurrency: AppCurrency, + isBalanceHidden: Boolean, + ): TokenActionsUM { + return if (designFeatureToggles.isRedesignEnabled) { + buildV2( + cryptoCurrencyData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + ) + } else { + buildV1( + cryptoCurrencyData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + ) + } + } + + private fun buildV1( + cryptoCurrencyData: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + ): TokenActionsUM { + val status = cryptoCurrencyData.status + val tokenUM = TokenItemState.Content( + id = status.currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), + titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), + fiatAmountState = null, + subtitle2State = null, + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return TokenActionsUM( + token = tokenUM, + quickActions = quickActions( + cryptoData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = false, + ), + onLaterClick = { + params.callbacks.onLaterClick() + }, + ) + } + + private fun buildV2( + cryptoCurrencyData: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + appCurrency: AppCurrency, + isBalanceHidden: Boolean, + ): TokenActionsUM { + val status = cryptoCurrencyData.status + val tokenUM = TokenItemState.Content( + id = status.currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), + titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), + fiatAmountState = createFiatAmountState(status, appCurrency), + subtitle2State = createSubtitle2State(status), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return TokenActionsUM( + token = tokenUM, + quickActions = quickActions( + cryptoData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = true, + ), + onLaterClick = { + params.callbacks.onLaterClick() + }, + isBalancesHidden = isBalanceHidden, + portfolioBadge = createPortfolioBadge(cryptoCurrencyData), + ) + } + + private fun createPortfolioBadge(cryptoCurrencyData: CryptoCurrencyData): TangemBadgeUM { + val icon: AccountIconUM? + val name = if (cryptoCurrencyData.isAccountMode) { + icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon) + cryptoCurrencyData + .account + .account + .accountName + .toUM() + .value + } else { + icon = null + stringReference(cryptoCurrencyData.userWallet.name) + } + return TangemBadgeUM( + text = name, + tangemIconUM = if (icon == null) { + TangemIconUM.Icon( + iconRes = R.drawable.ic_key_card_20, + tintReference = { TangemTheme.colors2.graphic.neutral.tertiary }, + ) + } else { + TangemIconUM.Icon( + iconRes = icon.value.getResId(), + tintReference = { icon.color.getUiColor() }, + ) + }, + size = TangemBadgeSize.X6, + shape = TangemBadgeShape.Rounded, + iconPosition = if (cryptoCurrencyData.isAccountMode) { + TangemBadgeIconPosition.Start + } else { + TangemBadgeIconPosition.End + }, + shouldRespectIconTint = cryptoCurrencyData.isAccountMode, + ) + } + + private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? { + return when (status.value) { + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> { + TokenItemState.Subtitle2State.TextContent( + text = status.getTotalCryptoAmount().format { + crypto(status.currency) + }, + ) + } + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> TokenItemState.Subtitle2State.TextContent( + text = BigDecimal.ZERO.format { + crypto(status.currency) + }, + ) + } + } + + private fun createFiatAmountState( + status: CryptoCurrencyStatus, + appCurrency: AppCurrency, + ): TokenItemState.FiatAmountState? { + return when (status.value) { + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> { + TokenItemState.FiatAmountState.AnnotatedContent( + text = status.getTotalFiatAmount().formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).price() + }, + ) + } + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Loading, + -> TokenItemState.FiatAmountState.AnnotatedContent( + text = BigDecimal.ZERO.formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).price() + }, + ) + } + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt new file mode 100644 index 0000000000..a55e97e605 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt @@ -0,0 +1,268 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +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.CompositionLocalProvider +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.label.Label +import com.tangem.core.ui.components.label.entity.LabelStyle +import com.tangem.core.ui.components.label.entity.LabelUM +import com.tangem.core.ui.components.rows.BlockchainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.badge.TangemBadgeShape +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM +import kotlinx.collections.immutable.persistentListOf +import java.util.UUID + +private const val DISABLED_ALPHA = 0.4f + +@Composable +internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + ChooseNetworkContentV2( + state = state, + modifier = modifier, + ) + } else { + ChooseNetworkContentV1( + state = state, + modifier = modifier, + ) + } +} + +@Composable +internal fun ChooseNetworkContentV1(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(TangemTheme.colors.background.action), + ) { + state.networks.fastForEach { model -> + key(model.id) { + BlockchainRow( + model = model, + itemPadding = PaddingValues( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing14, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }), + ) { + if (!model.isEnabled) { + Label( + modifier = Modifier.alpha(DISABLED_ALPHA), + state = LabelUM( + text = resourceReference(R.string.common_added), + style = LabelStyle.REGULAR, + ), + ) + } + } + } + } + } +} + +@Composable +internal fun ChooseNetworkContentV2(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(34.dp), + ) + .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2), + ) { + Text( + modifier = Modifier.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2), + text = stringResourceSafe(R.string.common_choose_network), + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + + state.networks.fastForEach { model -> + key(model.id) { + TangemRowContainer( + modifier = Modifier.clickable( + enabled = model.isEnabled, + onClick = { state.onNetworkClick(model) }, + ), + contentPadding = PaddingValues(vertical = TangemTheme.dimens2.x3), + content = { + NetworkIcon( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + model = model, + ) + + NetworkText( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + model = model, + ) + if (!model.isEnabled) { + TangemBadge( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x2), + text = resourceReference(R.string.common_added), + size = TangemBadgeSize.X6, + shape = TangemBadgeShape.Rounded, + ) + } + }, + ) + } + } + } +} + +@Composable +private fun NetworkIcon(model: BlockchainRowUM, modifier: Modifier = Modifier) { + if (model.isSelected && model.isEnabled) { + Image( + modifier = modifier + .size(TangemTheme.dimens2.x10), + painter = painterResource(id = model.iconResId), + contentDescription = null, + ) + } else { + Icon( + modifier = modifier + .background( + color = TangemTheme.colors2.button.backgroundSecondary, + shape = CircleShape, + ) + .size(TangemTheme.dimens2.x10), + painter = painterResource(id = model.iconResId), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Composable +private fun NetworkText(model: BlockchainRowUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + modifier = Modifier.weight(weight = 10f, fill = false), + text = model.name, + style = TangemTheme.typography2.bodyMedium16, + color = when { + model.isEnabled && model.isMainNetwork -> TangemTheme.colors2.text.neutral.primary + model.isEnabled -> TangemTheme.colors2.text.neutral.secondary + else -> TangemTheme.colors2.text.status.disabled + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier.weight(weight = 5f, fill = false), + text = model.type, + style = TangemTheme.typography2.captionMedium12, + color = if (model.isEnabled) { + TangemTheme.colors2.text.neutral.tertiary + } else { + TangemTheme.colors2.text.status.disabled + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreview { + ChooseNetworkContent( + state = content, + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewV2(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + ChooseNetworkContent( + state = content, + ) + } + } +} + +internal class ChooseNetworkContentProvider : PreviewParameterProvider { + + private val blockchainRow = BlockchainRowUM( + id = UUID.randomUUID().toString(), + name = "Etherium 3", + type = "TEST", + iconResId = R.drawable.img_eth_22, + isMainNetwork = false, + isSelected = true, + isEnabled = true, + ) + + override val values: Sequence + get() = sequenceOf( + ChooseNetworkUM( + onNetworkClick = {}, + networks = persistentListOf( + blockchainRow.copy( + type = "MAIN", + isMainNetwork = true, + ), + blockchainRow.copy( + iconResId = R.drawable.ic_bsc_16, + isEnabled = false, + ), + blockchainRow.copy(iconResId = R.drawable.img_polygon_22), + blockchainRow.copy(iconResId = R.drawable.img_optimism_22), + ), + ), + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt new file mode 100644 index 0000000000..7b833e9e32 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt @@ -0,0 +1,125 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui + +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.* +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.AvailableToAddDataConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* + +internal class DefaultAddToPortfolioManager @AssistedInject constructor( + private val availableToAddDataConverter: AvailableToAddDataConverter, + @Assisted override val settings: Settings, + @Assisted override val analyticsParams: AnalyticsParams, + @Assisted val scope: CoroutineScope, + dispatchers: CoroutineDispatcherProvider, + portfolioFetcherFactory: PortfolioFetcher.Factory, +) : AddToPortfolioManager { + + override val onDismiss: Channel = Channel() + override val onSuccessAdded: Channel = Channel() + override val onAddedTokenClick: Channel = Channel() + + override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), + scope = scope, + ) + + private val internalParamsFlow = MutableStateFlow(ParamsInternal()) + + override val paramsFlow = internalParamsFlow + .transform { internalParams -> + val fullParams = Params( + networks = internalParams.networks ?: return@transform, + token = internalParams.token ?: return@transform, + launchMode = internalParams.launchMode, + ) + emit(fullParams) + } + .distinctUntilChanged() + .shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1) + + override val state: MutableStateFlow = MutableStateFlow(State.Loading) + + init { + buildFlow() + .onEach { newState -> state.update { newState } } + .flowOn(dispatchers.default) + .launchIn(scope) + } + + override fun onDismiss() { + onDismiss.trySend(Unit) + } + + override fun onSuccessAdded(result: Result) { + onSuccessAdded.trySend(result) + } + + override fun onAddedTokenClick(result: Result) { + onAddedTokenClick.trySend(result) + } + + override fun setTokenNetworks(networks: List) { + updateInternal(networks = networks) + } + + override fun setTokenParams(token: RawMarketToken) { + updateInternal(token = token) + } + + override fun updateLaunchMode(launchMode: LaunchMode) { + updateInternal(launchMode = launchMode) + } + + private fun updateInternal( + networks: List? = null, + token: RawMarketToken? = null, + launchMode: LaunchMode? = null, + ) { + internalParamsFlow.update { prev -> + val newParams = ParamsInternal( + networks = networks ?: prev.networks, + token = token ?: prev.token, + launchMode = launchMode ?: prev.launchMode, + ) + val shouldReload = newParams != prev + if (shouldReload) state.update { State.Loading } + return@update newParams + } + } + + private fun buildFlow(): Flow = combine( + flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), + flow2 = paramsFlow, + ) { balances, (availableNetworks, token) -> + val data = availableToAddDataConverter.convert( + balances = balances, + availableNetworks = availableNetworks.toSet(), + marketParams = token, + ) + State.Ready(data) + } + + @AssistedFactory + interface Factory : AddToPortfolioManager.Factory { + override fun create( + scope: CoroutineScope, + settings: Settings, + analyticsParams: AnalyticsParams, + ): DefaultAddToPortfolioManager + } + + private data class ParamsInternal( + val launchMode: LaunchMode = LaunchMode.DirectAdd, + val networks: List? = null, + val token: RawMarketToken? = null, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt similarity index 90% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt index d88388bb30..1b7f686fb7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi @@ -22,6 +22,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.markets.action.QuickActionUM +import com.tangem.common.ui.markets.action.QuickActions import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH16 @@ -37,10 +39,8 @@ import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import kotlinx.collections.immutable.persistentListOf import java.util.UUID @@ -123,7 +123,7 @@ private fun ActionRow( .size(36.dp) .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + if (state is QuickActionUM.V1.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -188,11 +188,11 @@ private class TokenActionsContentPreviewProvider : PreviewParameterProvider get() = sequenceOf( TokenActionsUM( - quickActions = PortfolioTokenUM.QuickActions( + quickActions = QuickActions( actions = persistentListOf( - QuickActionUM.Buy, - QuickActionUM.Exchange(shouldShowBadge = true), - QuickActionUM.Receive, + QuickActionUM.V1.Buy, + QuickActionUM.V1.Exchange(shouldShowBadge = true), + QuickActionUM.V1.Receive, ), onQuickActionClick = {}, onQuickActionLongClick = {}, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt new file mode 100644 index 0000000000..569d3233b4 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt @@ -0,0 +1,295 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +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.CompositionLocalProvider +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.markets.action.QuickActionUM +import com.tangem.common.ui.markets.action.QuickActions +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.badge.* +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.format.bigdecimal.price +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.* +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM +import dev.chrisbanes.haze.rememberHazeState +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal +import java.util.UUID + +private const val ACTION_BACKGROUND_ALPHA = .1f + +@Composable +internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenHeader( + addedToken = state.token, + portfolioBadge = state.portfolioBadge, + isBalanceHidden = state.isBalancesHidden, + ) + + SpacerH(TangemTheme.dimens2.x2) + + Column( + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + state.quickActions.actions.fastForEach { actionUM -> + key(actionUM.title) { + ActionRow( + state = actionUM, + onClick = { state.quickActions.onQuickActionClick(actionUM) }, + onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }, + ) + } + } + } + + SpacerH(TangemTheme.dimens2.x2) + + CompositionLocalProvider(LocalHazeState provides rememberHazeState()) { + SecondaryTangemButton( + modifier = Modifier.fillMaxWidth(), + onClick = state.onLaterClick, + text = resourceReference(R.string.common_later), + size = TangemButtonSize.X12, + shape = TangemButtonShape.Rounded, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ActionRow( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit), + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal = { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + + TangemRowContainer( + modifier = modifier + .combinedClickable( + onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable }, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + Box( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(40.dp) + .background( + color = TangemTheme.colors2.graphic.status.accent.copy(alpha = ACTION_BACKGROUND_ALPHA), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors2.graphic.status.accent, + ) + } + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = state.title.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + ) + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = state.description.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + ) + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x2) + .size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Composable +private fun TokenHeader( + addedToken: TokenItemState, + isBalanceHidden: Boolean, + portfolioBadge: TangemBadgeUM?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x8), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + state = addedToken.iconState, + iconSize = 70.dp, + networkBadgeSize = 24.dp, + ) + + SpacerH(TangemTheme.dimens2.x5) + + when (val fiat = addedToken.fiatAmountState) { + is TokenItemState.FiatAmountState.AnnotatedContent -> { + Text( + text = fiat.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + style = TangemTheme.typography2.titleRegular44, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x2) + } + else -> Unit + } + + when (val cryptoAmount = addedToken.subtitle2State) { + is TokenItemState.Subtitle2State.TextContent -> { + Text( + text = cryptoAmount.text.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.secondary, + ) + SpacerH(TangemTheme.dimens2.x2) + } + else -> Unit + } + + SpacerH(TangemTheme.dimens2.x7) + + if (portfolioBadge == null) return + TangemBadge(portfolioBadge) + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(TokenActionsContentPreviewProviderV2::class) state: TokenActionsUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level2) + .padding(horizontal = 16.dp), + ) { + TokenActionsContentV2( + state = state, + ) + } + } + } +} + +private class TokenActionsContentPreviewProviderV2 : PreviewParameterProvider { + private val tokenState + get() = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = TokenItemState.FiatAmountState.AnnotatedContent( + text = BigDecimal.ONE.formatStyled { + fiat( + fiatCurrencyCode = "USD", + fiatCurrencySymbol = "$", + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).price() + }, + ), + subtitle2State = TokenItemState.Subtitle2State.TextContent( + "1.01 USDT", + ), + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = {}, + onItemLongClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + TokenActionsUM( + quickActions = QuickActions( + actions = persistentListOf( + QuickActionUM.V2.Buy, + QuickActionUM.V2.Exchange(shouldShowBadge = true), + QuickActionUM.V2.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + token = tokenState, + onLaterClick = {}, + portfolioBadge = TangemBadgeUM( + text = stringReference("Wallet 2"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_key_card_20, + tintReference = { TangemTheme.colors2.graphic.neutral.tertiary }, + ), + size = TangemBadgeSize.X6, + shape = TangemBadgeShape.Rounded, + iconPosition = TangemBadgeIconPosition.End, + ), + ), + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/ChooseNetworkUM.kt similarity index 73% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/ChooseNetworkUM.kt index 8f54e3cb3b..89fde91bde 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/ChooseNetworkUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state import com.tangem.core.ui.components.rows.model.BlockchainRowUM import kotlinx.collections.immutable.ImmutableList diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt new file mode 100644 index 0000000000..8af3513085 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state + +import com.tangem.common.ui.markets.action.QuickActions +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.badge.TangemBadgeUM + +internal data class TokenActionsUM( + val token: TokenItemState, + val quickActions: QuickActions, + val onLaterClick: () -> Unit, + val isBalancesHidden: Boolean = false, + val portfolioBadge: TangemBadgeUM? = null, +) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt new file mode 100644 index 0000000000..42d36a8582 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.markets.tokenselector.TokenSelectorEmbeddedContent +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultUserPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: UserPortfolioComponent.Params, +) : UserPortfolioComponent, AppComponentContext by context { + + private val model: UserPortfolioModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val stateFlow = model.state.collectAsStateWithLifecycle() + val state = stateFlow.value ?: return + TokenSelectorEmbeddedContent( + content = state.content, + modifier = modifier, + scrollBottomInset = LocalTangemBottomSheetContentBottomInset.current, + ) + } + + @AssistedFactory + interface Factory : UserPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: UserPortfolioComponent.Params, + ): DefaultUserPortfolioComponent + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/UserPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/UserPortfolioComponent.kt new file mode 100644 index 0000000000..b827f3568d --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/UserPortfolioComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import kotlinx.coroutines.flow.StateFlow + +internal interface UserPortfolioComponent : ComposableContentComponent { + + data class Params( + val uiState: StateFlow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onContinueFromUserPortfolio() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioModel.kt new file mode 100644 index 0000000000..9bd839e9b9 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioModel.kt @@ -0,0 +1,20 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class UserPortfolioModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + + val state: StateFlow = params.uiState +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioUM.kt new file mode 100644 index 0000000000..bece060837 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM + +@Immutable +internal data class UserPortfolioUM( + val content: TokenSelectorContentUM, + val isAddEnabled: Boolean, +) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/state/UserPortfolioStateController.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/state/UserPortfolioStateController.kt new file mode 100644 index 0000000000..2feeec1c92 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/state/UserPortfolioStateController.kt @@ -0,0 +1,57 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state + +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer.UserPortfolioSectionsTransformer +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class UserPortfolioStateController @AssistedInject constructor( + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + @Assisted private val modelScope: CoroutineScope, + @Assisted private val onTokenSelected: (AddToPortfolioManager.Result) -> Unit, +) { + + private val requiredDataFlow = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val uiState: StateFlow = combine( + flow = requiredDataFlow.distinctUntilChanged(), + flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { (allAvailableData, rawCurrencyId), appCurrency, isBalanceHidden -> + UserPortfolioSectionsTransformer( + availableData = allAvailableData, + rawCurrencyId = rawCurrencyId, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenSelected = onTokenSelected, + ).transform() + } + .distinctUntilChanged() + .stateIn(modelScope, SharingStarted.Lazily, null) + + suspend fun updateAndWaitNotNullState(allAvailableData: AvailableToAddData, rawCurrencyId: CryptoCurrency.RawID) { + requiredDataFlow.tryEmit(allAvailableData to rawCurrencyId) + uiState.filterNotNull().firstOrNull() + } + + @AssistedFactory + interface Factory { + fun create( + modelScope: CoroutineScope, + onTokenSelected: (AddToPortfolioManager.Result) -> Unit, + ): UserPortfolioStateController + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/transformer/UserPortfolioSectionsTransformer.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/transformer/UserPortfolioSectionsTransformer.kt new file mode 100644 index 0000000000..1258e4fad7 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/transformer/UserPortfolioSectionsTransformer.kt @@ -0,0 +1,187 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer + +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.markets.tokenselector.* +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +internal class UserPortfolioSectionsTransformer( + private val availableData: AvailableToAddData, + private val rawCurrencyId: CryptoCurrency.RawID, + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenSelected: (AddToPortfolioManager.Result) -> Unit, +) { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + fun transform(): UserPortfolioUM { + return UserPortfolioUM( + content = TokenSelectorContentUM( + sections = buildSections(entries = generateEntries(availableData)).toImmutableList(), + ), + isAddEnabled = availableData.isAvailableToAdd, + ) + } + + private fun generateEntries(data: AvailableToAddData): List { + return data.availableToAddWallets.values.flatMap { wallet -> + wallet.accounts.filterCryptoPortfolio().flatMap { accountStatus -> + accountStatus.tokenList.flattenCurrencies() + .filter { status -> status.currency.matchesRawId(rawCurrencyId) } + .map { status -> + PortfolioEntry( + wallet = wallet.userWallet, + account = accountStatus, + currencyStatus = status, + ) + } + } + } + } + + private fun buildSections(entries: List): List { + val sections = mutableListOf() + val byWallet = entries.groupBy { it.wallet.walletId } + val shouldShowWalletHeaders = byWallet.size > 1 + + for ((_, walletEntries) in byWallet) { + if (shouldShowWalletHeaders) { + sections.add( + TokenSelectorSectionUM.WalletHeader(walletName = walletEntries.first().wallet.name), + ) + } + + val byAccount = walletEntries.groupBy { it.account.account.accountId } + val shouldShowAccountHeaders = byAccount.size > 1 + + for ((_, accountEntries) in byAccount) { + val singles = accountEntries.map(::entryToSingle).toImmutableList() + val accountHeader = if (shouldShowAccountHeaders) { + val first = accountEntries.first() + AccountHeaderData( + accountName = first + .account + .account + .accountName + .toUM() + .value, + cryptoPortfolioIcon = first.account.account.icon, + ) + } else { + null + } + sections.add( + TokenSelectorSectionUM.TokenGroup(accountHeader = accountHeader, items = singles), + ) + } + } + return sections + } + + private fun entryToSingle(entry: PortfolioEntry): UserAssetItemUM.Single { + val currency = entry.currencyStatus.currency + val value = entry.currencyStatus.value + return UserAssetItemUM.Single( + id = "${entry.wallet.walletId.stringValue}_${entry.account.account.accountId.value}_${currency.id.value}", + icon = TangemIconUM.Currency(currencyIconState = iconConverter.convert(entry.currencyStatus)), + tokenName = currency.name, + tokenSymbol = currency.symbol, + fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, + priceChangeState = when (value) { + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + -> PriceChangeState.Unknown + else -> PriceChangeState.Content( + type = PriceChangeType.fromBigDecimal(value.priceChange.orZero()), + valueInPercent = value.priceChange.format { percent() }, + ) + }, + balanceState = convertBalanceState(value, currency.symbol, currency.decimals), + isBalanceHidden = isBalanceHidden, + onClick = { + onTokenSelected( + AddToPortfolioManager.Result( + wallet = entry.wallet, + account = entry.account, + addedCurrency = entry.currencyStatus, + ), + ) + }, + networkName = currency.network.name, + ) + } + + private data class PortfolioEntry( + val wallet: UserWallet, + val account: AccountStatus.CryptoPortfolio, + val currencyStatus: CryptoCurrencyStatus, + ) + + private fun convertBalanceState( + value: CryptoCurrencyStatus.Value, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + return when { + value is CryptoCurrencyStatus.Loading && value.amount != null -> + BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading + value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable + value.isError && value.amount != null -> + BalanceDisplayState.Stale( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value.isError -> BalanceDisplayState.Unreachable + else -> BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + } + } + + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { + return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN + } + + private fun CryptoCurrency.matchesRawId(target: CryptoCurrency.RawID): Boolean { + val rawId = id.rawCurrencyId ?: return false + return getTokenIdIfL2Network(rawId.value) == target.value + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenBridge.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenBridge.kt new file mode 100644 index 0000000000..3037369099 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenBridge.kt @@ -0,0 +1,88 @@ +package com.tangem.features.commonfeatures.impl.choosetoken + +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge.Settings +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioFullBlockDelegate +import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioListBlockDelegate +import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* + +internal class DefaultChooseTokenBridge @AssistedInject constructor( + @Assisted private val modelScope: CoroutineScope, + portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory, + portfolioFullBlockDelegateFactory: PortfolioFullBlockDelegate.Factory, + @Assisted override val settings: Settings, + @Assisted override val analyticsPayload: Set, +) : ChooseTokenBridge { + + override val onCurrencyChosen: Channel = Channel() + override val onClose: Channel = Channel() + + private val onSearchQuery: Channel = Channel() + override val searchQueryState: StateFlow = onSearchQuery.receiveAsFlow() + .debounce(ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY) + .stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty) + + private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create( + modelScope = modelScope, + searchQueryState = searchQueryState, + featureSettings = settings, + ) + + private val portfolioFullBlockDelegate: PortfolioFullBlockDelegate = portfolioFullBlockDelegateFactory.create( + modelScope = modelScope, + searchQueryState = searchQueryState, + portfolioListBlockDelegate = portfolioListBlockDelegate, + ) + + override val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> + get() = portfolioListBlockDelegate.tokenFilter + + override val fullPortfolioBlock: StateFlow + get() = portfolioFullBlockDelegate.fullPortfolioBlock + + init { + portfolioListBlockDelegate.onTokenChosen.receiveAsFlow() + .onEach { chooseResult -> onCurrencyChosen(chooseResult) } + .launchIn(modelScope) + } + + override fun selectWalletTab(walletId: UserWalletId) { + portfolioFullBlockDelegate.selectWalletTab(walletId) + } + + override fun onSearchQuery(query: SearchQuery) { + onSearchQuery.trySend(query) + } + + override fun onCurrencyChosen(result: ChooseTokenResult) { + onCurrencyChosen.trySend(result) + onSearchQuery(SearchQuery.Empty) + } + + override fun onClose() { + onClose.trySend(Unit) + onSearchQuery(SearchQuery.Empty) + } + + @AssistedFactory + interface Factory : ChooseTokenBridge.Factory { + override fun create( + modelScope: CoroutineScope, + settings: Settings, + analyticsPayload: Set, + ): DefaultChooseTokenBridge + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenComponent.kt similarity index 73% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenComponent.kt index 62bd2f26e5..7a55c72de3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl +package com.tangem.features.commonfeatures.impl.choosetoken import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -10,20 +10,21 @@ import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.Route import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent -import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel -import com.tangem.feature.swap.models.AddToPortfolioRoute -import com.tangem.feature.swap.ui.SwapSelectTokenScreen -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenScreen +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable internal class DefaultChooseTokenComponent @AssistedInject constructor( + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, @Assisted appComponentContext: AppComponentContext, @Assisted private val params: ChooseTokenComponent.Params, - private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : AppComponentContext by appComponentContext, ChooseTokenComponent { private val model: ChooseTokenModel = getOrCreateModel(params) @@ -38,14 +39,9 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val stateOld by model.stateOld.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() - stateOld?.let { stateHolder -> - SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() }) - } - // todo swap uncomment - // val state by model.state.collectAsStateWithLifecycle() - // ChooseTokenScreen(state = state) + val state by model.state.collectAsStateWithLifecycle() + ChooseTokenScreen(state = state) bottomSheet.child?.instance?.BottomSheet() } @@ -54,9 +50,7 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( return addToPortfolioComponentFactory.create( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( - addToPortfolioManager = model.addToPortfolioManager!!, - callback = model.addToPortfolioCallback, - shouldSkipTokenActionsScreen = true, + addToPortfolioManager = model.addToPortfolioManager, ), ) } @@ -72,4 +66,7 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( private companion object { const val BOTTOM_SHEET_SLOT_KEY = "choosePortfolioTokenBottomSheetSlot" } -} \ No newline at end of file +} + +@Serializable +internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/SettingContextUseCase.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/SettingContextUseCase.kt similarity index 94% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/SettingContextUseCase.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/SettingContextUseCase.kt index 9eee2c081b..d382260ecd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/SettingContextUseCase.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/SettingContextUseCase.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.api +package com.tangem.features.commonfeatures.impl.choosetoken import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import javax.inject.Inject -// todo swap move to some common module class SettingContextUseCase @Inject constructor( private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt new file mode 100644 index 0000000000..aacbdb1b48 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt @@ -0,0 +1,231 @@ +package com.tangem.features.commonfeatures.impl.choosetoken.converter + +import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter +import com.tangem.common.ui.account.TokensListPortfolioItemConverter +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.tokens.TokenConverterParams +import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems +import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems +import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState +import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.choosetoken.model.ClickIntents +import kotlinx.collections.immutable.toPersistentList + +internal class ChooseTokenListItemConverter( + private val appCurrency: AppCurrency, + private val params: TokenConverterParams, + private val clickIntents: ClickIntents, + private val searchQuery: SearchQuery, + private val tokenFilter: (AccountStatus, CryptoCurrencyStatus) -> Boolean, + private val isShowPaymentAccount: Boolean, +) { + + private val isSearchingState: Boolean get() = searchQuery.isSearchingState + + private val onTokenClick: (account: AccountStatus, currencyStatus: CryptoCurrencyStatus) -> Unit = + { account, currencyStatus -> + clickIntents.onTokenItemClick(account, currencyStatus) + } + + private val onAccountItemClick: (Account, isExpanded: Boolean) -> Unit = { clickedAccount, isExpanded -> + if (isExpanded) { + clickIntents.onAccountCollapseClick(clickedAccount) + } else { + clickIntents.onAccountExpandClick(clickedAccount) + } + } + + private val fiatAmountStateProvider: ((TotalFiatBalance, isExpanded: Boolean) -> FiatAmountState?) = + { totalBalance, isExpanded -> + when { + isSearchingState -> FiatAmountState.Empty + !isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) + else -> AccountCryptoPortfolioItemStateConverter + .createFiatAmountState(totalBalance, appCurrency) + } + } + + private fun tokenStatusConverter(account: AccountStatus) = TokenItemStateConverter( + appCurrency = appCurrency, + onItemClick = { _, status -> onTokenClick(account, status) }, + ) + + fun convert(): TokenListUMData { + return when (params) { + is TokenConverterParams.Account -> convertAccountList(params) + is TokenConverterParams.Wallet -> convertTokenList( + account = params.mainAccount, + tokenConverter = tokenStatusConverter(params.mainAccount), + tokenListParam = params.tokenList, + ) + } + } + + private fun convertAccountList(params: TokenConverterParams.Account): TokenListUMData { + val accountList = params.accountList + val accountItems = accountList.accountStatuses + .mapNotNull { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.toPortfolioItem(params) + is AccountStatus.Payment -> accountStatus.createPaymentAccountItem(params.expandedAccounts) + } + } + .filter { portfolio -> portfolio.tokens.isNotEmpty() } + if (accountItems.isEmpty()) { + return TokenListUMData.EmptyList + } + return TokenListUMData.AccountList( + tokensList = accountItems.toPersistentList(), + totalTokensCount = accountItems.sumOf { portfolio -> portfolio.tokensItemsList.size }, + ) + } + + private fun AccountStatus.CryptoPortfolio.toPortfolioItem( + params: TokenConverterParams.Account, + ): TokensListItemUM.Portfolio { + val tokenList: TokenList = this.tokenList + val account: Account.CryptoPortfolio = this.account + val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId) + val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount -> + onAccountItemClick(clickedAccount, isExpanded) + } + + val converter = AccountCryptoPortfolioItemStateConverter( + appCurrency = appCurrency, + account = account, + onItemClick = onItemClick.takeIf { !isSearchingState }, + priceChangeLce = this.priceChangeLce, + fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) }, + subtitle2StateProvider = { _ -> null }, + ) + val accountItem = converter.convert(tokenList.totalFiatBalance) + val tokenConverter = tokenStatusConverter(this) + val tokensListState = convertTokenList(tokenConverter, tokenList, this) + val items = tokensListState.tokensList + return TokensListPortfolioItemConverter( + tokenItemUM = accountItem, + isExpanded = isExpanded, + isCollapsable = !isSearchingState, + tokens = items.filterIsInstance().toPersistentList(), + ).convert(Unit) + } + + private fun convertTokenList( + tokenConverter: TokenItemStateConverter, + tokenListParam: TokenList, + account: AccountStatus.CryptoPortfolio, + ): TokenListUMData { + return when (val tokenList = filterTokenList(tokenListParam, account)) { + is TokenList.Empty -> TokenListUMData.EmptyList + is TokenList.GroupedByNetwork -> TokenListUMData.TokenList( + tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = tokenList.flattenCurrencies().size, + ) + is TokenList.Ungrouped -> TokenListUMData.TokenList( + tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = tokenList.flattenCurrencies().size, + ) + } + } + + private fun List.filterCurrencies(account: AccountStatus): List = + filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) } + + private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList { + return when (tokenList) { + TokenList.Empty -> TokenList.Empty + is TokenList.Ungrouped -> { + val filtered = tokenList.currencies.filterCurrencies(account) + if (filtered.isEmpty()) TokenList.Empty else tokenList.copy(currencies = filtered) + } + is TokenList.GroupedByNetwork -> { + val filteredGroups = tokenList.groups + .map { group -> + val filteredCurrencies = group.currencies.filterCurrencies(account) + group.copy(currencies = filteredCurrencies) + } + .filter { group -> group.currencies.isNotEmpty() } + if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups) + } + } + } + + private fun CryptoCurrencyStatus.filterByQuery(): Boolean { + if (!isSearchingState) return true + val isSearchFilter = currency.name.contains(searchQuery.value, ignoreCase = true) || + currency.symbol.contains(searchQuery.value, ignoreCase = true) + return isSearchFilter + } + + private fun AccountStatus.Payment.createPaymentAccountItem( + expandedAccounts: Set, + ): TokensListItemUM.Portfolio? { + if (!isShowPaymentAccount) return null + val paymentCurrency: CryptoCurrencyStatus = when (val status = this.value) { + is PaymentAccountStatusValue.Error, + is PaymentAccountStatusValue.IssuingCard, + PaymentAccountStatusValue.NotCreated, + is PaymentAccountStatusValue.UnderReview, + PaymentAccountStatusValue.Loading, + PaymentAccountStatusValue.Empty, + is PaymentAccountStatusValue.Deactivated, + -> return null + is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus + } + val account = this.account + val tokensCount = 1 + val isExpanded = isSearchingState || expandedAccounts.contains(account.accountId) + val onItemClick: (TokenItemState) -> Unit = { + onAccountItemClick(account, isExpanded) + } + val fiatBalance: TotalFiatBalance = this.value.totalFiatBalance + val fiatAmountState = fiatAmountStateProvider(fiatBalance, isExpanded) + val paymentAccountItem = TokenItemState.Content( + id = account.accountId.value, + iconState = CurrencyIconState.PaymentAccount(), + titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = onItemClick.takeIf { !isSearchingState }, + fiatAmountState = fiatAmountState, + subtitle2State = null, + onItemLongClick = null, + ) + val tokenConverter = tokenStatusConverter(this) + val filtered = listOf(paymentCurrency) + .filterCurrencies(this) + .map { currency -> TokensListItemUM.Token(tokenConverter.convert(currency)) } + + return TokensListPortfolioItemConverter( + tokenItemUM = paymentAccountItem, + isExpanded = isExpanded, + isCollapsable = !isSearchingState, + tokens = filtered.toPersistentList(), + ).convert(Unit) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarToggleTransformer.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarToggleTransformer.kt similarity index 70% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarToggleTransformer.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarToggleTransformer.kt index 94dc7a09a9..41a7a2a758 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarToggleTransformer.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarToggleTransformer.kt @@ -1,6 +1,6 @@ -package com.tangem.feature.swap.choosetoken.impl.converter +package com.tangem.features.commonfeatures.impl.choosetoken.converter -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM import com.tangem.utils.transformer.Transformer internal class SearchBarToggleTransformer(private val isActive: Boolean) : Transformer { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarUpdateQueryTransformer.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarUpdateQueryTransformer.kt similarity index 70% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarUpdateQueryTransformer.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarUpdateQueryTransformer.kt index 33cf694057..94c6e2d19c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarUpdateQueryTransformer.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarUpdateQueryTransformer.kt @@ -1,6 +1,6 @@ -package com.tangem.feature.swap.choosetoken.impl.converter +package com.tangem.features.commonfeatures.impl.choosetoken.converter -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM import com.tangem.utils.transformer.Transformer internal class SearchBarUpdateQueryTransformer(private val newQuery: String) : Transformer { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/di/ChooseTokenModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/di/ChooseTokenModule.kt similarity index 65% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/di/ChooseTokenModule.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/di/ChooseTokenModule.kt index 48e130b850..e3a3b614b6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/di/ChooseTokenModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/di/ChooseTokenModule.kt @@ -1,12 +1,12 @@ -package com.tangem.feature.swap.choosetoken.impl.di +package com.tangem.features.commonfeatures.impl.choosetoken.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge -import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent -import com.tangem.feature.swap.choosetoken.impl.DefaultChooseTokenBridge -import com.tangem.feature.swap.choosetoken.impl.DefaultChooseTokenComponent -import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenBridge +import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenComponent +import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/MarketsListBatchFlowManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt similarity index 98% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/MarketsListBatchFlowManager.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt index 64d320b24a..bff9bfb159 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/MarketsListBatchFlowManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt @@ -1,11 +1,11 @@ -package com.tangem.feature.swap.models.market +package com.tangem.features.commonfeatures.impl.choosetoken.market import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.models.market.converter.SwapMarketsTokenItemConverter +import com.tangem.features.commonfeatures.impl.choosetoken.market.converter.SwapMarketsTokenItemConverter import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.PaginationStatus diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/converter/SwapMarketsTokenItemConverter.kt similarity index 98% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/converter/SwapMarketsTokenItemConverter.kt index 0e693eab2d..e42f02c207 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/converter/SwapMarketsTokenItemConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.models.market.converter +package com.tangem.features.commonfeatures.impl.choosetoken.market.converter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartRawData @@ -16,7 +16,7 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.format.bigdecimal.price import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket -import com.tangem.feature.swap.presentation.R +import com.tangem.features.commonfeatures.impl.R import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt similarity index 96% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt index e18aad5dd7..dd11a69c73 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.models.market.state +package com.tangem.features.commonfeatures.impl.choosetoken.market.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt new file mode 100644 index 0000000000..4687184b49 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt @@ -0,0 +1,121 @@ +package com.tangem.features.commonfeatures.impl.choosetoken.model + +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer +import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM +import com.tangem.features.commonfeatures.api.R +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class ChooseTokenModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + marketBlockDelegateFactory: MarketBlockDelegate.Factory, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + private val bridge: ChooseTokenBridge = params.bridge + + private val searchQueryState: StateFlow = bridge.searchQueryState + private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState + private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create( + modelScope = modelScope, + searchQueryState = searchQueryState, + screensSourcesName = bridge.analyticsPayload + .filterIsInstance() + .firstOrNull()?.value.orEmpty(), + ) + + val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot + val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager + private val marketsStateFlow: Flow = if (bridge.settings.isShowMarketBlock) { + marketBlockDelegate.marketsStateFlow + } else { + flowOf(null) + } + + private val initialState: MutableStateFlow = MutableStateFlow(getInitState()) + val state: StateFlow = combine( + flow = initialState, + flow2 = bridge.fullPortfolioBlock, + flow3 = marketsStateFlow, + transform = { initial, content, marketBlock -> + ChooseTokenFullUM( + initialUM = initial, + portfolioBlock = content, + marketsBlock = marketBlock, + ) + }, + ).stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = ChooseTokenFullUM( + initialUM = initialState.value, + portfolioBlock = bridge.fullPortfolioBlock.value, + marketsBlock = null, + ), + ) + + init { + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { addedResult -> + val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState) + val chooseTokenResult = ChooseTokenResult( + currency = addedResult.addedCurrency, + account = addedResult.account, + wallet = addedResult.wallet, + analyticsPayload = setOf(isSearched), + ) + bridge.onCurrencyChosen(chooseTokenResult) + marketBlockDelegate.addToPortfolioSlot.dismiss() + } + .launchIn(modelScope) + } + + fun onBackClicked() { + bridge.onClose() + } + + private fun getInitialSearchBar(): SearchBarUM = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + isActive = false, + onQueryChange = { query -> + initialState.update { prevState -> SearchBarUpdateQueryTransformer(query).transform(prevState) } + bridge.onSearchQuery(query) + }, + onActiveChange = { isActive -> + initialState.update { prevState -> SearchBarToggleTransformer(isActive).transform(prevState) } + }, + ) + + private fun getInitState() = ChooseTokenInitialUM( + screenTitle = bridge.settings.title, + onCloseClick = ::onBackClicked, + searchBar = getInitialSearchBar(), + ) + + companion object { + const val DEBOUNCE_SEARCH_DELAY = 500L + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt similarity index 72% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index d83753ba54..a13fbe77a5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl.model +package com.tangem.features.commonfeatures.impl.choosetoken.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate @@ -14,42 +14,47 @@ import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.feature.swap.models.AddToPortfolioRoute -import com.tangem.feature.swap.models.market.MarketsListBatchFlowManager -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute +import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch +import kotlin.collections.filter +import kotlin.collections.map +import kotlin.collections.orEmpty @Suppress("LongParameterList") internal class MarketBlockDelegate @AssistedInject constructor( private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory, - private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val excludedBlockchains: ExcludedBlockchains, private val getUserWalletsUseCase: GetWalletsUseCase, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, @Assisted private val modelScope: CoroutineScope, - @Assisted private val searchQueryState: StateFlow, + @Assisted private val searchQueryState: StateFlow, @Assisted private val screensSourcesName: String, ) { - private val addToPortfolioJobHolder = JobHolder() private val visibleMarketItemIds = MutableStateFlow>(emptyList()) private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) val addToPortfolioSlot: SlotNavigation = SlotNavigation() - var addToPortfolioManager: AddToPortfolioManager? = null + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.ChooseToken, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName), + ) val marketsStateFlow: Flow = searchQueryState // Switch between default and search market flows - .map { it.isEmpty() } + .map { it.value.isEmpty() } .distinctUntilChanged() .flatMapLatest { isDefaultMode -> if (isDefaultMode) { @@ -74,7 +79,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( marketsListBatchFlowManagerFactory.create( batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, order = TokenMarketListConfig.Order.ByRating, - currentSearchText = Provider { searchQueryState.value }, + currentSearchText = Provider { searchQueryState.value.value }, modelScope = modelScope, ) } @@ -83,8 +88,8 @@ internal class MarketBlockDelegate @AssistedInject constructor( // Reload search markets when query changes searchQueryState .onEach { searchQuery -> - if (searchQuery.isNotEmpty()) { - searchMarketsListManager.reload(searchQuery) + if (searchQuery.isSearchingState) { + searchMarketsListManager.reload(searchQuery.value) } } .launchIn(modelScope) @@ -157,7 +162,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( ) { uiItems, isError, isSearchNotFound, total -> when { isError -> SwapMarketState.LoadingError( - onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value) }, + onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value.value) }, marketsTitle = marketsTitle, shouldAssetsCount = true, ) @@ -177,51 +182,40 @@ internal class MarketBlockDelegate @AssistedInject constructor( } private fun addToPortfolioItem(item: MarketsListItemUM) { - modelScope.launch { - val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) - ?: searchMarketsListManager.getTokenMarketById(item.id) - ?: return@launch + val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) + ?: searchMarketsListManager.getTokenMarketById(item.id) ?: return - val param = tokenMarket.toSerializableParam() - val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + val param = tokenMarket.toSerializableParam() + val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } - val networks = tokenMarket.networks?.filter { network -> - BlockchainUtils.isSupportedNetworkId( - blockchainId = network.networkId, - coinId = tokenMarket.id.value, - contractAddress = network.contractAddress, - excludedBlockchains = excludedBlockchains, - hotExcludedBlockchains = hotWalletExcludedBlockchains, - hasOnlyHotWallets = hasOnlyHotWallets, - ) - }?.map { network -> - TokenMarketInfo.Network( - networkId = network.networkId, - isExchangeable = false, - contractAddress = network.contractAddress, - decimalCount = network.decimalCount, - ) - }.orEmpty() + val networks = tokenMarket.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + networkId = network.networkId, + coinId = tokenMarket.id.value, + contractAddress = network.contractAddress, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }?.map { network -> + TokenMarketInfo.Network( + networkId = network.networkId, + isExchangeable = false, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + }.orEmpty() - addToPortfolioManager = addToPortfolioManagerFactory - .create( - scope = modelScope, - token = param, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName), - ).apply { - setTokenNetworks(networks) - } + addToPortfolioManager.setTokenNetworks(networks) + addToPortfolioManager.setTokenParams(param) - addToPortfolioManager?.state - ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } - ?.run { addToPortfolioSlot.activate(AddToPortfolioRoute) } - }.saveIn(addToPortfolioJobHolder) + addToPortfolioSlot.activate(AddToPortfolioRoute) } @AssistedFactory interface Factory { fun create( - searchQueryState: StateFlow, + searchQueryState: StateFlow, modelScope: CoroutineScope, screensSourcesName: String, ): MarketBlockDelegate diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioFullBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioFullBlockDelegate.kt new file mode 100644 index 0000000000..8b1670bc5d --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioFullBlockDelegate.kt @@ -0,0 +1,114 @@ +package com.tangem.features.commonfeatures.impl.choosetoken.model + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* + +@Suppress("LongParameterList") +internal class PortfolioFullBlockDelegate @AssistedInject constructor( + private val settingContextUseCase: SettingContextUseCase, + private val getWalletsUseCase: GetWalletsUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val dispatchers: CoroutineDispatcherProvider, + private val selectedWalletUseCase: GetSelectedWalletUseCase, + @Assisted private val modelScope: CoroutineScope, + @Assisted private val portfolioListBlockDelegate: PortfolioListBlockDelegate, + @Assisted private val searchQueryState: StateFlow, +) { + + private val isSearchingState: Boolean get() = searchQueryState.isSearchingState + private val onWalletSelected = Channel(capacity = Channel.BUFFERED) + + val selectedWalletFlow: SharedFlow = onWalletSelected.receiveAsFlow() + .distinctUntilChanged() + .mapNotNull { walletId -> getUserWalletUseCase.invoke(walletId).getOrNull() } + .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) + + val fullPortfolioBlock: StateFlow = buildFlow() + .flowOn(dispatchers.default) + .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) + + init { + val globalSelectedWallet = selectedWalletUseCase.sync().getOrNull() + val allWallets = getWalletsUseCase.invokeSync().filter { it.isMultiCurrency } + val firstSelectedWallet = when { + globalSelectedWallet?.isMultiCurrency == true -> globalSelectedWallet + allWallets.isNotEmpty() -> allWallets.first() + else -> null + } + if (firstSelectedWallet != null) selectWalletTab(firstSelectedWallet.walletId) + } + + private fun buildFlow() = flow { + val walletsFlow = getWalletsUseCase.invokeAsMap() + .map { wallets -> wallets.filterNot { (_, wallet) -> wallet.isLocked } } + val fullPortfolioBlockFlow = combine( + flow = walletsFlow, + flow2 = portfolioListBlockDelegate.portfolioList, + flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), + flow4 = settingContextUseCase.invoke(), + transform = { allWallets, portfolioList, selectedWalletId, settings -> + val tokensListData = portfolioList[selectedWalletId] ?: return@combine null + val walletsUM = allWallets.entries + .map { (walletId, wallet) -> + val searchResultCount: TextReference? = portfolioList[walletId]?.totalTokensCount + ?.toString() + ?.let(::stringReference) + ?.takeIf { isSearchingState } + WalletTabUM( + text = stringReference(wallet.name), + onClick = { selectWalletTab(walletId) }, + isSelected = selectedWalletId == walletId, + count = searchResultCount, + ) + } + val walletListUM = if (walletsUM.size != 1) { + WalletListUM(walletsUM.toPersistentList()) + } else { + WalletListUM(persistentListOf()) + } + ChooseTokenPortfolioFullBlockUM( + walletList = walletListUM, + isBalanceHidden = settings.isBalanceHidden, + isSearching = isSearchingState, + tokensListData = tokensListData, + ) + }, + ) + emitAll(fullPortfolioBlockFlow) + } + + fun selectWalletTab(walletId: UserWalletId) { + onWalletSelected.trySend(walletId) + } + + @AssistedFactory + interface Factory { + fun create( + modelScope: CoroutineScope, + portfolioListBlockDelegate: PortfolioListBlockDelegate, + searchQueryState: StateFlow, + ): PortfolioFullBlockDelegate + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt similarity index 56% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt index 64558e8d15..f2acbf62c8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt @@ -1,9 +1,9 @@ -package com.tangem.feature.swap.choosetoken.impl.model +package com.tangem.features.commonfeatures.impl.choosetoken.model import com.tangem.common.ui.tokens.TokenConverterParams import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier -import com.tangem.domain.account.status.utils.ExpandedAccountsHolder +import com.tangem.domain.account.status.utils.ChooseTokenExpandedAccountsHolder import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus @@ -11,9 +11,14 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase -import com.tangem.feature.swap.choosetoken.impl.converter.ChooseTokenListItemConverter -import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData +import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase +import com.tangem.features.commonfeatures.impl.choosetoken.converter.ChooseTokenListItemConverter import com.tangem.utils.extensions.mapNotNullValues import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -22,23 +27,43 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* +@Suppress("LongParameterList") internal class PortfolioListBlockDelegate @AssistedInject constructor( - private val expandedAccountsHolder: ExpandedAccountsHolder, + private val expandedAccountsHolder: ChooseTokenExpandedAccountsHolder, private val settingContext: SettingContextUseCase, private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, private val getWalletsUseCase: GetWalletsUseCase, @Assisted private val modelScope: CoroutineScope, - @Assisted private val searchQueryState: StateFlow, + @Assisted private val searchQueryState: StateFlow, + @Assisted private val featureSettings: ChooseTokenBridge.Settings, ) : ClickIntents { - val onTokenItemClick: Channel> = Channel() + private val onTokenItemClick: Channel> = Channel() - val portfolioList: Flow> = flow { + val onTokenChosen: Channel = Channel() + val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> = + MutableStateFlow { _, _ -> true } + + val portfolioList: SharedFlow> = buildDataFlow() + .distinctUntilChanged() + .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) + + private fun buildDataFlow(): Flow> = channelFlow { val allAccountsFlow: Flow> = multiAccountStatusListSupplier.invokeAsMap() - val allWalletsFlow: Flow> = - getWalletsUseCase.invokeAsMap() + val allWalletsFlow: StateFlow> = + getWalletsUseCase.invokeAsMap().stateIn(this) + + onTokenItemClick.receiveAsFlow() + .onEach { (account, currencyStatus) -> + onTokenItemClick( + wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach, + account = account, + currencyStatus = currencyStatus, + ) + } + .launchIn(this) val expandedAccountsMapFlow = allWalletsFlow .map { allWallets -> allWallets.values.map { wallet -> wallet.walletId } } @@ -51,7 +76,8 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( flow2 = allAccountsFlow, flow3 = expandedAccountsMapFlow, flow4 = searchQueryState, - transform = { settings, allAccounts, expandedAccountsMap, searchQuery -> + flow5 = tokenFilter, + transform = { settings, allAccounts, expandedAccountsMap, searchQuery, tokenFilter -> allAccounts.mapNotNullValues { (walletId, statusList) -> val expandedAccounts = expandedAccountsMap[walletId].orEmpty() val converterParams = if (settings.isAccountsMode) { @@ -65,16 +91,16 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( params = converterParams, clickIntents = this@PortfolioListBlockDelegate, searchQuery = searchQuery, + tokenFilter = tokenFilter, + isShowPaymentAccount = featureSettings.isShowPaymentAccount, ).convert() um } }, ) - emitAll(finalFlow) + finalFlow.collectLatest { result -> channel.send(result) } } - .distinctUntilChanged() - .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) private fun List.toExpandedAccountsMap(): Flow>> { if (isEmpty()) return flowOf(emptyMap()) @@ -83,6 +109,19 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( return combine(flows, { pairs -> pairs.toMap() }) } + private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) { + val analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.IsSearched(searchQueryState.isSearchingState), + ) + val result = ChooseTokenResult( + account = account, + currency = currencyStatus, + wallet = wallet, + analyticsPayload = analyticsPayload, + ) + onTokenChosen.trySend(result) + } + override fun onTokenItemClick(account: AccountStatus, currencyStatus: CryptoCurrencyStatus) { onTokenItemClick.trySend(account to currencyStatus) } @@ -97,7 +136,11 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(searchQueryState: StateFlow, modelScope: CoroutineScope): PortfolioListBlockDelegate + fun create( + searchQueryState: StateFlow, + modelScope: CoroutineScope, + featureSettings: ChooseTokenBridge.Settings, + ): PortfolioListBlockDelegate } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt similarity index 54% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 626a24fe4e..1db37d2b53 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -1,15 +1,23 @@ -package com.tangem.feature.swap.choosetoken.impl.ui +package com.tangem.features.commonfeatures.impl.choosetoken.ui +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -17,10 +25,6 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.tokens.portfolioTokensList import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.components.buttons.common.TangemButton -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition -import com.tangem.core.ui.components.buttons.common.TangemButtonSize -import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults @@ -34,19 +38,19 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags +import com.tangem.core.ui.utils.TangemSharedTransitionLayout import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection -import com.tangem.feature.swap.models.TokenListUMData -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R -import com.tangem.feature.swap.ui.market.swapMarketsListItems -import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -54,6 +58,26 @@ import kotlin.random.Random private const val LOAD_MORE_BUFFER = 25 +private val ChooseTokenFullUM.isNotFoundState: Boolean + get() { + if (portfolioBlock == null) return false + if (marketsBlock == null) return false + return portfolioBlock.tokensListData.tokensList.isEmpty() && + portfolioBlock.isSearching && + marketsBlock !is SwapMarketState.Content && + marketsBlock !is SwapMarketState.Loading + } + +private val ChooseTokenFullUM.isEmptyState: Boolean + get() { + if (portfolioBlock == null) return false + if (marketsBlock == null) return false + return portfolioBlock.tokensListData.tokensList.isEmpty() && + !portfolioBlock.isSearching && + marketsBlock !is SwapMarketState.Content && + marketsBlock !is SwapMarketState.Loading + } + @Composable internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { Column( @@ -89,40 +113,46 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection() val lazyListState = rememberLazyListState() - LazyColumn( - modifier = modifier - .fillMaxSize() - .nestedScroll(nestedScrollConnection), - horizontalAlignment = Alignment.CenterHorizontally, - state = lazyListState, - contentPadding = WindowInsets.navigationBars.asPaddingValues(), - ) { - item(key = "search_bar") { - SearchBar( - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), - state = state.initialUM.searchBar, - colors = TangemSearchBarDefaults.secondaryTextFieldColors, - ) - } + TangemSharedTransitionLayout(modifier) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection), + state = lazyListState, + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { + item(key = "search_bar") { + SearchBar( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + state = state.initialUM.searchBar, + colors = TangemSearchBarDefaults.secondaryTextFieldColors, + ) + } - assetsTitle() + assetsTitle() - if (state.contentUM != null) { - walletListItem(state.contentUM.walletList) + if (state.portfolioBlock != null) { + walletListItem(state.portfolioBlock.walletList) + when { + state.isNotFoundState -> tokensNotFound() + state.isEmptyState -> emptyTokensList() + else -> { + tokensListItems( + tokensListData = state.portfolioBlock.tokensListData, + isBalanceHidden = state.portfolioBlock.isBalanceHidden, + ) - tokensListItems( - tokensListData = state.contentUM.tokensListData, - isBalanceHidden = state.contentUM.isBalanceHidden, - ) - - if (state.contentUM.marketsState != null) { - item("markets_title_spacer") { SpacerH(height = 20.dp) } - swapMarketsListItems(state.contentUM.marketsState) + if (state.marketsBlock != null) { + item("markets_title_spacer") { SpacerH(height = 20.dp) } + swapMarketsListItems(state.marketsBlock) + } + } + } } } } - if (state.contentUM?.marketsState != null) { - SetupMarketScrollTracker(state.contentUM.marketsState, lazyListState) + if (state.marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) { + SetupMarketScrollTracker(state.marketsBlock, lazyListState) } } @@ -183,26 +213,60 @@ private fun LazyListScope.assetsTitle() { private fun LazyListScope.walletListItem(walletList: WalletListUM) { if (walletList.items.isEmpty()) return item("wallet_list") { - Row( - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), + LazyRow( + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8), - verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(horizontal = 16.dp), ) { - walletList.items.forEach { um -> - val colors = when (um.type) { - TangemButtonType.Primary -> TangemButtonsDefaults.primaryButtonColors - else -> TangemButtonsDefaults.secondaryButtonColors - } - TangemButton( - text = um.text?.resolveReference().orEmpty(), - icon = TangemButtonIconPosition.None, - size = TangemButtonSize.Action, - colors = colors, - showProgress = false, - onClick = um.onClick, - enabled = true, + items(walletList.items) { um -> + WalletTabItem(um) + } + } + } +} + +@Composable +private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { + val isSelected = state.isSelected + val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary + val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1 + val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary + val countBackground = if (isSelected) { + TangemTheme.colors.button.secondary.copy(alpha = 0.2f) + } else { + TangemTheme.colors.button.primary.copy(alpha = 0.1f) + } + + Row( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickable(onClick = state.onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.text.resolveReference(), + color = buttonTextColor, + style = TangemTheme.typography.button, + ) + + val count = state.count + if (count != null) { + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .background(countBackground, shape = CircleShape) + .defaultMinSize(minWidth = 20.dp) + .padding(horizontal = 4.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = count.resolveReference(), + color = countTextColor, + style = TangemTheme.typography.caption1, ) } } @@ -253,6 +317,58 @@ private fun LazyListScope.tokensList(items: ImmutableList, isB ) } +private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) { + item("EmptyTokensList") { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillParentMaxSize(), + ) { + Column(modifier = Modifier.align(Alignment.Center)) { + Image( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.ic_no_token_44), + colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), + contentDescription = null, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.CenterHorizontally), + text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + } + } +} + +private fun LazyListScope.tokensNotFound(modifier: Modifier = Modifier) { + item("TokensNotFound") { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillParentMaxSize(), + ) { + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing32) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.TopCenter), + text = stringResourceSafe(id = R.string.express_token_list_empty_search), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + } +} + @Preview @Composable private fun TokenScreenPreview(@PreviewParameter(ChooseTokenScreenPreviewProvider::class) state: ChooseTokenFullUM) { @@ -321,32 +437,43 @@ private val accounts private val wallets get() = persistentListOf( - TangemButtonUM( + WalletTabUM( text = TextReference.Str(value = "Wallet 1"), - type = TangemButtonType.Primary, + isSelected = true, onClick = {}, + count = null, ), - TangemButtonUM( + WalletTabUM( + text = TextReference.Str(value = "Wallet 1"), + isSelected = true, + onClick = {}, + count = stringReference("3"), + ), + WalletTabUM( text = TextReference.Str(value = "Wallet 2"), - type = TangemButtonType.Secondary, + isSelected = false, onClick = {}, + count = stringReference("333"), ), - TangemButtonUM( + WalletTabUM( text = TextReference.Str(value = "Wallet 3"), - type = TangemButtonType.Secondary, + isSelected = false, onClick = {}, + count = null, ), ) +private val initialUM = ChooseTokenInitialUM( + screenTitle = stringReference("Choose token"), + onCloseClick = {}, + searchBar = searchBar, +) + private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( ChooseTokenFullUM( - initialUM = ChooseTokenInitialUM( - screenTitle = stringReference("Choose token"), - onCloseClick = {}, - searchBar = searchBar, - ), - contentUM = ChooseTokenUM( + initialUM = initialUM, + portfolioBlock = ChooseTokenPortfolioFullBlockUM( walletList = WalletListUM(wallets), isBalanceHidden = false, isSearching = false, @@ -354,8 +481,18 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider Unit, + val searchBar: SearchBarUM, +) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt similarity index 94% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt index ced4fbda3e..adf96a1463 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/market/SwapMarketsListLazyColumn.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapMarketsListLazyColumn.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.ui.market +package com.tangem.features.commonfeatures.impl.choosetoken.ui import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope @@ -17,8 +17,8 @@ import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { item { diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapSelectTokenPreviewProvider.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapSelectTokenPreviewProvider.kt new file mode 100644 index 0000000000..a8c1f2486f --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapSelectTokenPreviewProvider.kt @@ -0,0 +1,125 @@ +package com.tangem.features.commonfeatures.impl.choosetoken.ui + +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +internal object SwapSelectTokenPreviewProvider { + + private const val CHART_VALUE_1 = 0.4 + private const val CHART_VALUE_2 = 0.2 + private const val CHART_VALUE_3 = 0.1 + private const val CHART_VALUE_4 = 2.0 + private const val CHART_VALUE_5 = 5.0 + private const val CHART_VALUE_6 = 3.0 + private const val TOTAL_ITEMS = 322 + + private val PREVIEW_CHART_DATA = MarketChartRawData( + y = persistentListOf( + CHART_VALUE_1, + CHART_VALUE_2, + CHART_VALUE_1, + CHART_VALUE_3, + CHART_VALUE_1, + CHART_VALUE_4, + CHART_VALUE_5, + CHART_VALUE_3, + CHART_VALUE_4, + CHART_VALUE_4, + CHART_VALUE_6, + ), + ) + + val marketState = SwapMarketState.Content( + items = createPreviewMarketItems(), + loadMore = { }, + onItemClick = { }, + visibleIdsChanged = { }, + total = TOTAL_ITEMS, + marketsTitle = TextReference.Res(R.string.feed_trending_now), + shouldAssetsCount = false, + ) + + private fun createPreviewMarketItems() = listOf( + createMarketItem( + id = "1", + iconUrl = "", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "2", + ratingPosition = "10", + marketCap = "$6.233 B", + trendType = PriceChangeType.NEUTRAL, + chartData = null, + ), + createMarketItem( + id = "3", + name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", + ratingPosition = "10", + marketCap = "$6.23348172384781234 B", + trendType = PriceChangeType.DOWN, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "4", + ratingPosition = "10", + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "5", + ratingPosition = null, + marketCap = "$6.233 B", + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + createMarketItem( + id = "6", + ratingPosition = null, + marketCap = null, + trendType = PriceChangeType.UP, + chartData = PREVIEW_CHART_DATA, + ), + ).toImmutableList() + + private fun createMarketItem( + id: String, + name: String = "Bitcoin", + iconUrl: String? = null, + ratingPosition: String?, + marketCap: String?, + trendType: PriceChangeType, + chartData: MarketChartRawData?, + ) = MarketsListItemUM( + id = CryptoCurrency.RawID(id), + name = name, + currencySymbol = "BTC", + iconUrl = iconUrl, + ratingPosition = ratingPosition, + marketCap = marketCap, + price = MarketsListItemUM.Price( + text = "31 285.72$", + annotated = stringReference("31 285.72$"), + fiatPrice = BigDecimal("123123"), + ), + trendPercentText = "12.43%", + trendType = trendType, + chartData = chartData, + isUnder100kMarketCap = false, + stakingRate = stringReference("APY 12.34%"), + updateTimestamp = 0, + ) +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt similarity index 61% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt index 2f0229f8f3..3346d4017d 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt @@ -1,15 +1,19 @@ -package com.tangem.features.account.selector +package com.tangem.features.commonfeatures.impl.portfolioselector +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.bottomsheets.LocalTangemBottomSheetContentBottomInset import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.selector.ui.PortfolioSelectorBS -import com.tangem.features.account.selector.ui.PortfolioSelectorContent +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorBS +import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorContent +import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorContentV2 import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -43,10 +47,20 @@ internal class DefaultPortfolioSelectorComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - PortfolioSelectorContent( - state = state, - modifier = modifier, - ) + val listBottomPadding = PaddingValues(bottom = LocalTangemBottomSheetContentBottomInset.current) + if (LocalRedesignEnabled.current) { + PortfolioSelectorContentV2( + state = state, + modifier = modifier, + contentPadding = listBottomPadding, + ) + } else { + PortfolioSelectorContent( + state = state, + modifier = modifier, + contentPadding = listBottomPadding, + ) + } } @AssistedFactory diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorController.kt similarity index 90% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorController.kt index e2da9828c4..69065a9436 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorController.kt @@ -1,12 +1,12 @@ -package com.tangem.features.account.selector +package com.tangem.features.commonfeatures.impl.portfolioselector import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt similarity index 95% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt index 58b5fd61ca..deb65e62a0 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector +package com.tangem.features.commonfeatures.impl.portfolioselector import com.tangem.common.ui.account.AccountPortfolioItemUMConverter import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter @@ -15,11 +15,11 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.impl.R -import com.tangem.features.account.selector.entity.PortfolioSelectorItemUM -import com.tangem.features.account.selector.entity.PortfolioSelectorUM +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/di/PortfolioSelectorModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/di/PortfolioSelectorModule.kt new file mode 100644 index 0000000000..976eeb78d1 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/di/PortfolioSelectorModule.kt @@ -0,0 +1,43 @@ +package com.tangem.features.commonfeatures.impl.portfolioselector.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController +import com.tangem.features.commonfeatures.impl.portfolioselector.DefaultPortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.portfolioselector.DefaultPortfolioSelectorController +import com.tangem.features.commonfeatures.impl.portfolioselector.PortfolioSelectorModel +import com.tangem.features.commonfeatures.impl.portfolioselector.fetcher.DefaultPortfolioFetcher +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface PortfolioSelectorModule { + + @Binds + @IntoMap + @ClassKey(PortfolioSelectorModel::class) + fun portfolioSelectorModel(model: PortfolioSelectorModel): Model +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface PortfolioSelectorSingletonModule { + + @Binds + fun bindPortfolioFetcherFactory(impl: DefaultPortfolioFetcher.Factory): PortfolioFetcher.Factory + + @Binds + fun bindPortfolioSelectorController(impl: DefaultPortfolioSelectorController): PortfolioSelectorController + + @Binds + fun bindPortfolioSelectorComponentFactory( + impl: DefaultPortfolioSelectorComponent.Factory, + ): PortfolioSelectorComponent.Factory +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/entity/PortfolioSelectorUM.kt similarity index 90% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/entity/PortfolioSelectorUM.kt index 83d3979381..109e9e4fd1 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/entity/PortfolioSelectorUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector.entity +package com.tangem.features.commonfeatures.impl.portfolioselector.entity import androidx.compose.runtime.Immutable import com.tangem.common.ui.userwallet.state.UserWalletItemUM diff --git a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/fetcher/DefaultPortfolioFetcher.kt similarity index 94% rename from features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/fetcher/DefaultPortfolioFetcher.kt index 9fb0924c4a..5bb30cc8f2 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/fetcher/DefaultPortfolioFetcher.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.fetcher +package com.tangem.features.commonfeatures.impl.portfolioselector.fetcher import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer @@ -9,8 +9,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioFetcher.* +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt similarity index 58% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt index 5e63cd75cc..14db910070 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt @@ -1,9 +1,10 @@ -package com.tangem.features.account.selector.ui +package com.tangem.features.commonfeatures.impl.portfolioselector.ui import android.content.res.Configuration import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -12,10 +13,12 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.account.impl.R -import com.tangem.features.account.selector.entity.PortfolioSelectorUM +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM @Composable internal fun PortfolioSelectorBS( @@ -41,11 +44,19 @@ internal fun PortfolioSelectorBS( ) }, content = { - PortfolioSelectorContent( - state = state, - contentPadding = PaddingValues(bottom = 16.dp), - modifier = modifier.padding(horizontal = 16.dp), - ) + if (LocalRedesignEnabled.current) { + PortfolioSelectorContentV2( + state = state, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = modifier.padding(horizontal = 16.dp), + ) + } else { + PortfolioSelectorContent( + state = state, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = modifier.padding(horizontal = 16.dp), + ) + } }, ) } @@ -62,4 +73,20 @@ private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::cla onBack = {}, ) } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewV2(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + PortfolioSelectorBS( + state = params, + onDismiss = {}, + modifier = Modifier, + onBack = {}, + ) + } + } } \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt similarity index 91% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt index 6b8f1872fd..d739e4d5a9 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector.ui +package com.tangem.features.commonfeatures.impl.portfolioselector.ui import android.content.res.Configuration import androidx.compose.foundation.BorderStroke @@ -32,13 +32,9 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.account.impl.R -import com.tangem.features.account.selector.entity.PortfolioSelectorItemUM -import com.tangem.features.account.selector.entity.PortfolioSelectorUM -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.firstList -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.lockedWalletList -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.secondList -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.walletList +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM import kotlinx.collections.immutable.toImmutableList import java.util.UUID @@ -207,19 +203,19 @@ internal class PortfolioSelectorPreviewStateProvider : CollectionPreviewParamete listOf( PortfolioSelectorUM( title = resourceReference(R.string.common_choose_account), - items = firstList.toImmutableList(), + items = PortfolioSelectorPreviewData.firstList.toImmutableList(), ), PortfolioSelectorUM( title = resourceReference(R.string.common_choose_account), - items = secondList.toImmutableList(), + items = PortfolioSelectorPreviewData.secondList.toImmutableList(), ), PortfolioSelectorUM( title = resourceReference(R.string.common_choose_wallet), - items = walletList.toImmutableList(), + items = PortfolioSelectorPreviewData.walletList.toImmutableList(), ), PortfolioSelectorUM( title = resourceReference(R.string.common_choose_wallet), - items = lockedWalletList.toImmutableList(), + items = PortfolioSelectorPreviewData.lockedWalletList.toImmutableList(), ), ), ) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt new file mode 100644 index 0000000000..1d7b42931f --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt @@ -0,0 +1,228 @@ +package com.tangem.features.commonfeatures.impl.portfolioselector.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.userwallet.CardImage +import com.tangem.common.ui.userwallet.getBalanceValueAndFlickerState +import com.tangem.common.ui.userwallet.getInformationValue +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.conditional +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM +import com.tangem.utils.StringsSigns.DOT + +private const val DISABLED_WALLET_ALPHA = 0.5f + +@Composable +internal fun PortfolioSelectorContentV2( + state: PortfolioSelectorUM, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + ) { + val items = state.items + itemsIndexed( + items = items, + key = { _, item -> item.id }, + ) { index, item -> + when (item) { + is PortfolioSelectorItemUM.Portfolio -> + PortfolioSelectorItem( + state = item.item, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = TangemTheme.colors2.surface.level3, + radius = TangemTheme.dimens2.x5, + addDefaultPadding = false, + ) + .clickable(enabled = item.item.isEnabled, onClick = item.item.onClick) + .conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) }, + ) + is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow( + model = item, + modifier = Modifier + .fillMaxWidth() + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + backgroundColor = TangemTheme.colors2.surface.level3, + radius = TangemTheme.dimens2.x5, + addDefaultPadding = false, + ) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + } + } +} + +@Composable +private fun PortfolioSelectorItem(state: UserWalletItemUM, modifier: Modifier = Modifier) { + TangemRowContainer(modifier = modifier) { + Box( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + contentAlignment = Alignment.Center, + ) { + CardImage( + modifier = Modifier.size(40.dp), + imageState = state.imageState, + ) + } + + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = state.name.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + InfoRow( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), + information = state.information, + balance = state.balance, + ) + } +} + +@Composable +private fun InfoRow( + information: UserWalletItemUM.Information, + balance: UserWalletItemUM.Balance, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent( + targetState = information, + label = "Information content", + ) { information -> + val informationValue = getInformationValue(information) + + if (informationValue == null) { + TextShimmer( + style = TangemTheme.typography2.captionMedium12, + text = "aaaaa", + ) + } else { + Text( + text = informationValue, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } + } + + Row { + Text( + text = " $DOT ", + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + + val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + + if (balanceValue == null) { + TextShimmer( + style = TangemTheme.typography2.captionMedium12, + text = "aaaaa", + ) + } else { + Text( + text = balanceValue, + style = TangemTheme.typography2.captionMedium12.applyBladeBrush( + isEnabled = isFlickering, + textColor = TangemTheme.colors2.text.neutral.secondary, + ), + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun WalletNameRow(model: PortfolioSelectorItemUM.GroupTitle, modifier: Modifier = Modifier) { + Row( + modifier = modifier.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = model.name.resolveReference(), + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_key_card_20), + modifier = Modifier + .align(Alignment.Bottom) + .size(TangemTheme.dimens2.x5), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + PortfolioSelectorContentV2( + state = params, + modifier = Modifier.background(color = TangemTheme.colors.background.tertiary), + ) + } + } + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt new file mode 100644 index 0000000000..0e42020f57 --- /dev/null +++ b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt @@ -0,0 +1,701 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AddToPortfolioInitialSelectionResolverTest { + + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency = mockk() + private val networkHasDerivationUseCase: NetworkHasDerivationUseCase = mockk() + + private val tokenParams: RawMarketToken = mockk() + + private lateinit var resolver: AddToPortfolioInitialSelectionResolver + + @BeforeEach + fun setup() { + clearMocks(getTokenMarketCryptoCurrency, networkHasDerivationUseCase) + coEvery { getTokenMarketCryptoCurrency(any(), any(), any(), any()) } returns null + every { networkHasDerivationUseCase(any(), any()) } returns false.right() + + resolver = AddToPortfolioInitialSelectionResolver( + getTokenMarketCryptoCurrency = getTokenMarketCryptoCurrency, + networkHasDerivationUseCase = networkHasDerivationUseCase, + ) + } + + @Test + fun `GIVEN no wallets in data WHEN resolve THEN return null`() = runTest { + val data = availableData(wallets = emptyMap()) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = null, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNull() + } + + @Test + fun `GIVEN accountToAdd is provided WHEN resolve THEN use it instead of looking up account`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val mainAccount = availableAccount() + val explicitAccount = availableAccount(availableToAddNetworks = setOf(BITCOIN)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to mainAccount), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount) + Truth.assertThat(result.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN accountToAdd is not available to add WHEN resolve THEN still use it`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(isAvailableToAdd = false, availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount) + } + + @Test + fun `GIVEN accountToAdd with no matching ordered networks WHEN resolve THEN fall back to first ordered network`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount) + Truth.assertThat(result.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN selected wallet is in data WHEN resolve THEN pick its entry`() = runTest { + val selectedWalletId = UserWalletId(WALLET_ID_A) + val otherWalletId = UserWalletId(WALLET_ID_B) + val selectedUserWallet = userWallet(selectedWalletId) + val otherUserWallet = userWallet(otherWalletId) + + val selectedAccount = availableAccount() + val otherAccount = availableAccount() + + val data = availableData( + wallets = linkedMapOf( + otherWalletId to walletEntry(otherUserWallet, mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherAccount)), + selectedWalletId to walletEntry(selectedUserWallet, mapOf(AccountId.forMainCryptoPortfolio(selectedWalletId) to selectedAccount)), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = selectedUserWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(selectedUserWallet) + Truth.assertThat(result.account).isSameInstanceAs(selectedAccount) + } + + @Test + fun `GIVEN selected wallet is not in data WHEN resolve THEN fall back to first wallet`() = runTest { + val firstWalletId = UserWalletId(WALLET_ID_A) + val firstUserWallet = userWallet(firstWalletId) + val firstAccount = availableAccount() + + val data = availableData( + wallets = mapOf( + firstWalletId to walletEntry(firstUserWallet, mapOf(AccountId.forMainCryptoPortfolio(firstWalletId) to firstAccount)), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet(UserWalletId(WALLET_ID_B)), + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(firstUserWallet) + } + + @Test + fun `GIVEN selected wallet is null WHEN resolve THEN fall back to first wallet`() = runTest { + val firstWalletId = UserWalletId(WALLET_ID_A) + val firstUserWallet = userWallet(firstWalletId) + val firstAccount = availableAccount() + + val data = availableData( + wallets = mapOf( + firstWalletId to walletEntry(firstUserWallet, mapOf(AccountId.forMainCryptoPortfolio(firstWalletId) to firstAccount)), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = null, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(firstUserWallet) + } + + @Test + fun `GIVEN main account is available WHEN resolve THEN pick main account`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val mainAccount = availableAccount() + val otherAccount = availableAccount() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = linkedMapOf( + AccountId.forPaymentAccount(walletId) to otherAccount, + AccountId.forMainCryptoPortfolio(walletId) to mainAccount, + ), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(mainAccount) + } + + @Test + fun `GIVEN main account is not available WHEN resolve THEN pick first available account`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val mainAccount = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + val secondAvailable = availableAccount() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = linkedMapOf( + AccountId.forMainCryptoPortfolio(walletId) to mainAccount, + AccountId.forPaymentAccount(walletId) to secondAvailable, + ), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(secondAvailable) + } + + @Test + fun `GIVEN no available accounts WHEN resolve THEN fall back to that account with first ordered network`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val unavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to unavailable), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(userWallet) + Truth.assertThat(result.account).isSameInstanceAs(unavailable) + Truth.assertThat(result.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN ordered networks do not match account networks WHEN resolve THEN fall back to first ordered network`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(userWallet) + Truth.assertThat(result.account).isSameInstanceAs(account) + Truth.assertThat(result.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN preferred wallet has no available accounts but another wallet does WHEN resolve THEN pick the other`() = + runTest { + val preferredWalletId = UserWalletId(WALLET_ID_A) + val otherWalletId = UserWalletId(WALLET_ID_B) + val preferredUserWallet = userWallet(preferredWalletId) + val otherUserWallet = userWallet(otherWalletId) + + val preferredUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + val otherAvailable = availableAccount(availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = linkedMapOf( + preferredWalletId to walletEntry( + userWallet = preferredUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(preferredWalletId) to preferredUnavailable), + ), + otherWalletId to walletEntry( + userWallet = otherUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherAvailable), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = preferredUserWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(otherUserWallet) + Truth.assertThat(result.account).isSameInstanceAs(otherAvailable) + Truth.assertThat(result.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN no wallet has viable combo WHEN resolve THEN fall back to preferred wallet with first ordered network`() = + runTest { + val preferredWalletId = UserWalletId(WALLET_ID_A) + val otherWalletId = UserWalletId(WALLET_ID_B) + val preferredUserWallet = userWallet(preferredWalletId) + val otherUserWallet = userWallet(otherWalletId) + + val preferredUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + val otherUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + + val data = availableData( + wallets = linkedMapOf( + otherWalletId to walletEntry( + userWallet = otherUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherUnavailable), + ), + preferredWalletId to walletEntry( + userWallet = preferredUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(preferredWalletId) to preferredUnavailable), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = preferredUserWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(preferredUserWallet) + Truth.assertThat(result.account).isSameInstanceAs(preferredUnavailable) + Truth.assertThat(result.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN first ordered network has derivation WHEN resolve THEN pick first`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns true.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN only second ordered network has derivation WHEN resolve THEN pick second`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns false.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN no network has derivation WHEN resolve THEN fall back to first ordered available network`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), any()) } returns Throwable().left() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN, ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN accountToAdd and preferredNetwork is on account and addable WHEN resolve THEN return preferred`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns true.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN, ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + preferredNetwork = ETHEREUM, + ) + + Truth.assertThat(result?.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN accountToAdd and preferred is not on account WHEN resolve THEN use pickAddableNetwork`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns true.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + preferredNetwork = POLYGON, + ) + + Truth.assertThat(result?.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN accountToAdd and preferred on account has no derivation WHEN resolve THEN fall back to pickAddableNetwork`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns false.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + preferredNetwork = ETHEREUM, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN get token market crypto currency returns null WHEN resolve THEN fall back to first ordered available network`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + coEvery { getTokenMarketCryptoCurrency(any(), any(), any(), any()) } returns null + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN, ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + + // region Helpers + + private fun availableData(wallets: Map): AvailableToAddData = mockk { + every { isAvailableToAdd } returns true + every { availableToAddWallets } returns wallets + } + + private fun walletEntry( + userWallet: UserWallet, + accounts: Map, + ): AvailableToAddWallet = mockk { + every { this@mockk.userWallet } returns userWallet + every { availableToAddAccounts } returns accounts + } + + private fun availableAccount( + isAvailableToAdd: Boolean = true, + availableToAddNetworks: Set = setOf(ETHEREUM), + derivationIndex: DerivationIndex = DerivationIndex.Main, + ): AvailableToAddAccount = mockk { + every { this@mockk.isAvailableToAdd } returns isAvailableToAdd + every { this@mockk.availableToAddNetworks } returns availableToAddNetworks + every { account.account.derivationIndex } returns derivationIndex + } + + private fun userWallet(walletId: UserWalletId): UserWallet = mockk { + every { this@mockk.walletId } returns walletId + } + + private fun cryptoCurrency(): CryptoCurrency = mockk { + every { network } returns mockk() + } + + // endregion + + private companion object { + const val WALLET_ID_A = "011f" + const val WALLET_ID_B = "022e" + + val ETHEREUM = TokenMarketInfo.Network( + networkId = "ethereum", + isExchangeable = true, + contractAddress = null, + decimalCount = 18, + ) + + val BITCOIN = TokenMarketInfo.Network( + networkId = "bitcoin", + isExchangeable = true, + contractAddress = null, + decimalCount = 8, + ) + + val POLYGON = TokenMarketInfo.Network( + networkId = "polygon", + isExchangeable = true, + contractAddress = null, + decimalCount = 18, + ) + } +} \ No newline at end of file diff --git a/features/create-wallet-start/impl/build.gradle.kts b/features/create-wallet-start/impl/build.gradle.kts index c280d4160c..f62c9a51f6 100644 --- a/features/create-wallet-start/impl/build.gradle.kts +++ b/features/create-wallet-start/impl/build.gradle.kts @@ -11,10 +11,15 @@ android { namespace = "com.tangem.features.createwalletstart.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.createWalletStart.api) implementation(projects.features.hotWallet.api) + implementation(projects.features.onboardingV2.api) /** Project - Domain */ implementation(projects.domain.card) @@ -71,4 +76,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 5f590c887c..9a2539c546 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -33,14 +33,15 @@ import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val HIDE_PROGRESS_DELAY = 400L @@ -65,6 +66,7 @@ internal class CreateWalletStartModel @Inject constructor( private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, private val appsFlyerStore: AppsFlyerStore, + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -234,7 +236,18 @@ internal class CreateWalletStartModel @Inject constructor( }, ifRight = { setLoading(false) - appRouter.replaceAll(AppRoute.Wallet) + val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWallet.walletId, + isWalletStarted = false, + ), + ) + } else { + AppRoute.Wallet + } + appRouter.replaceAll(route) }, ) } diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt new file mode 100644 index 0000000000..28520a0603 --- /dev/null +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -0,0 +1,652 @@ +package com.tangem.features.createwalletstart + +import arrow.core.left +import arrow.core.right +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent +import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.analytics.IntroductionProcess +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.SaveWalletError +import com.tangem.domain.hotwallet.IsHotWalletCreationSupported +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +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 CreateWalletStartModelTest { + + private val router: Router = mockk(relaxed = true) + private val appRouter: AppRouter = mockk(relaxed = true) + private val scanCardProcessor: ScanCardProcessor = mockk() + private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true) + private val settingsRepository: SettingsRepository = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk() + private val coldUserWalletBuilder: ColdUserWalletBuilder = mockk() + private val saveWalletUseCase: SaveWalletUseCase = mockk() + private val isHotWalletCreationSupported: IsHotWalletCreationSupported = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase = mockk() + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val appsFlyerStore: AppsFlyerStore = mockk() + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles = mockk() + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val testScanResponse: ScanResponse = mockk(relaxed = true) + private val testColdWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns testUserWalletId + } + + @BeforeEach + fun setUp() { + coEvery { appsFlyerStore.get() } returns null + coEvery { settingsRepository.shouldSaveAccessCodes() } returns false + every { coldUserWalletBuilderFactory.create(any()) } returns coldUserWalletBuilder + every { coldUserWalletBuilder.build() } returns testColdWallet + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } just Runs + } + + @Test + fun `GIVEN ColdWallet mode WHEN onScanClick THEN ButtonScanCard event sent AND scan called`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { + it.source == AnalyticsParam.ScreensSources.CreateWalletIntro + }, + ) + } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN HotWallet mode WHEN onScanClick THEN ButtonScanCard event sent AND scan called`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { + it.source == AnalyticsParam.ScreensSources.CreateWalletIntro + }, + ) + } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN ColdWallet AND hot wallet not supported WHEN otherMethodClick THEN dialog sent`() = runTest { + every { isHotWalletCreationSupported() } returns false + every { isHotWalletCreationSupported.getLeastVersionName() } returns "13" + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.otherMethodClick.invoke() + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send( + match { true }, + ) + } + verify { uiMessageSender.send(match { true }) } + verify(exactly = 0) { router.push(any(), any()) } + } + + @Test + fun `GIVEN HotWallet AND hot wallet supported WHEN onPrimaryButtonClick THEN CreateMobileWallet pushed`() = + runTest { + every { isHotWalletCreationSupported() } returns true + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.onPrimaryButtonClick.invoke() + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send( + match { true }, + ) + } + verify { + router.push( + route = AppRoute.CreateMobileWallet( + source = AnalyticsParam.ScreensSources.CreateWalletIntro.value, + ), + onComplete = any(), + ) + } + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN ColdWallet mode WHEN onPrimaryButtonClick THEN buy link opened`() = runTest { + val testUrl = "https://buy.tangem.com" + coEvery { + generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) + } returns testUrl + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onPrimaryButtonClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + coVerify { generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) } + verify { urlOpener.openUrl(testUrl) } + } + + @Test + fun `GIVEN HotWallet mode WHEN onBuyClick THEN buy link opened`() = runTest { + val testUrl = "https://buy.tangem.com" + coEvery { + generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) + } returns testUrl + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.otherMethodClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + coVerify { generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) } + verify { urlOpener.openUrl(testUrl) } + } + + @Test + fun `WHEN scanCard THEN access code policy set AND scanProcessor called`() = runTest { + coEvery { settingsRepository.shouldSaveAccessCodes() } returns true + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN NfcFeatureIsUnavailable WHEN handleScanError THEN nfcFeatureUnavailable dialog sent`() = runTest { + val error = TangemSdkError.NfcFeatureIsUnavailable() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(6) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { uiMessageSender.send(match { true }) } + } + + @Test + fun `GIVEN generic TangemSdkError WHEN handleScanError THEN no dialog sent`() = runTest { + val error: TangemSdkError = mockk(relaxed = true) + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(6) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN non-sdk TangemError WHEN handleScanError THEN no dialog sent`() = runTest { + val error: TangemError = mockk(relaxed = true) + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(6) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN builder returns null WHEN proceedWithScanResponse THEN saveWalletUseCase not called`() = runTest { + every { coldUserWalletBuilder.build() } returns null + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(7) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { coldUserWalletBuilderFactory.create(scanResponse = testScanResponse) } + coVerify(exactly = 0) { saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) } + verify(exactly = 0) { appRouter.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN save returns WalletAlreadySaved WHEN proceedWithScanResponse THEN unlock called AND Wallet replaced`() = + runTest { + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns SaveWalletError.WalletAlreadySaved(messageId = 0).left() + coEvery { + userWalletsListRepository.unlock( + userWalletId = testUserWalletId, + unlockMethod = any(), + ) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(7) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + coVerify { + userWalletsListRepository.unlock( + userWalletId = testUserWalletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(testScanResponse), + ) + } + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + } + + @Test + fun `GIVEN save returns DataError WHEN proceedWithScanResponse THEN no unlock AND no replace`() = runTest { + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns SaveWalletError.DataError(messageId = 0).left() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(7) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + coVerify(exactly = 0) { userWalletsListRepository.unlock(any(), any()) } + verify(exactly = 0) { appRouter.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN save success AND isAddressSyncEnabled disabled WHEN proceedWithScanResponse THEN Wallet replaced`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(7) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + } + + @Test + fun `GIVEN save success AND isAddressSyncEnabled WHEN proceedWithScanResponse THEN AddressSync replaced`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns true + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(7) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { + appRouter.replaceAll( + routes = arrayOf( + AppRoute.Onboarding( + scanResponse = testScanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = testUserWalletId, + isWalletStarted = false, + ), + ) + ), + onComplete = any() + ) + } + } + + @Test + fun `WHEN model initialized THEN CreateWalletIntroScreenOpened event sent with referral id`() = runTest { + val refcode = "referralCode" + coEvery { appsFlyerStore.get() } returns AppsFlyerConversionData(refcode = refcode, campaign = null) + + createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + } + + @Test + fun `WHEN ColdWallet WHEN get model THEN correct resources`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + val state = model.uiState.value + assert(state.title == resourceReference(R.string.common_tangem_wallet)) + assert(state.description == resourceReference(R.string.welcome_create_wallet_hardware_description)) + assert( + state.featureItems == persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ) + ) + ) + assert(state.imageResId == R.drawable.img_hardware_wallet) + assert(state.shouldShowScanSecondaryButton) + assert(state.primaryButtonText == resourceReference(R.string.details_buy_wallet)) + assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_mobile_title)) + assert(state.otherMethodDescription == null) + assert(state.isScanInProgress.not()) + } + + @Test + fun `WHEN HotWallet WHEN get model THEN correct resources`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + val state = model.uiState.value + assert(state.title == resourceReference(R.string.hw_mobile_wallet)) + assert(state.description == resourceReference(R.string.welcome_create_wallet_mobile_description_full)) + assert( + state.featureItems == persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_seamless), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_one_tap), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_stack_fill_new_16, + text = resourceReference(R.string.welcome_create_wallet_feature_assets), + ), + ) + ) + assert(state.imageResId == R.drawable.img_mobile_wallet) + assert(state.shouldShowScanSecondaryButton.not()) + assert(state.primaryButtonText == resourceReference(R.string.welcome_create_wallet_mobile_title)) + assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_use_hardware_title)) + assert(state.otherMethodDescription == resourceReference(R.string.welcome_create_wallet_use_hardware_description)) + assert(state.isScanInProgress.not()) + } + + private fun createModel( + testScope: TestScope, + mode: CreateWalletStartComponent.Mode, + paramsContainer: ParamsContainer = MutableParamsContainer( + value = CreateWalletStartComponent.Params(mode = mode) + ), + ): CreateWalletStartModel { + return CreateWalletStartModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + scanCardProcessor = scanCardProcessor, + cardSdkConfigRepository = cardSdkConfigRepository, + settingsRepository = settingsRepository, + appRouter = appRouter, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + saveWalletUseCase = saveWalletUseCase, + isHotWalletCreationSupported = isHotWalletCreationSupported, + userWalletsListRepository = userWalletsListRepository, + uiMessageSender = uiMessageSender, + generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase, + urlOpener = urlOpener, + trackingContextProxy = trackingContextProxy, + analyticsEventHandler = analyticsEventHandler, + appsFlyerStore = appsFlyerStore, + onboardingV2FeatureToggles = onboardingV2FeatureToggles, + ) + } + + 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/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 742988403b..f0cda1eba5 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.details.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /* Project - API */ @@ -19,8 +23,7 @@ dependencies { implementation(projects.features.disclaimer.api) implementation(projects.features.tester.api) implementation(projects.features.createWalletSelection.api) - implementation(projects.features.hotWallet.api) - implementation(projects.features.tangempay.details.api) + implementation(projects.features.onboardingV2.api) /* Project - Core */ implementation(projects.core.decompose) @@ -49,6 +52,7 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.legacy) + implementation(projects.domain.settings) implementation(projects.domain.visa) /* SDK */ @@ -78,7 +82,13 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) implementation(deps.arrow.core) implementation(deps.arrow.fx) + + /* Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 552842c6e5..15b152f01b 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -6,13 +6,15 @@ import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.navigation.url.DummyUrlOpener import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsUM import com.tangem.features.details.ui.DetailsScreen import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder -import com.tangem.features.hotwallet.HotWalletFeatureToggles +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking internal class PreviewDetailsComponent : DetailsComponent { @@ -20,9 +22,10 @@ internal class PreviewDetailsComponent : DetailsComponent { private val previewBlocks = runBlocking { ItemsBuilder( router = DummyRouter(), - hotWalletFeatureToggles = object : HotWalletFeatureToggles { - override val isWalletCreationRestrictionEnabled: Boolean = true - override val isTokenSyncEnabled: Boolean = true + hotWalletRestrictionManager = object : HotWalletRestrictionManager { + override fun isCreationEnabled(): StateFlow = MutableStateFlow(true) + override fun isCreationEnabledSync(): Boolean = true + override suspend fun toggleCreationEnabled() = Unit }, ).buildAll( isWalletConnectAvailable = true, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index a4d4c58b0a..840d7776a1 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -1,8 +1,6 @@ package com.tangem.features.details.model -import android.content.res.Resources import arrow.core.getOrElse -import com.tangem.utils.logging.TangemLogger import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -22,8 +20,6 @@ import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint -import com.tangem.domain.redux.LegacyAction -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase @@ -38,7 +34,8 @@ import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn @@ -46,7 +43,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import java.util.Locale import javax.inject.Inject @ModelScoped @@ -56,12 +52,11 @@ internal class DetailsModel @Inject constructor( paramsContainer: ParamsContainer, feedbackFeatureToggles: FeedbackFeatureToggles, private val itemsBuilder: ItemsBuilder, - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase, private val router: Router, private val urlOpener: UrlOpener, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - private val appStateHolder: ReduxStateHolder, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, @@ -79,9 +74,6 @@ internal class DetailsModel @Inject constructor( val state: MutableStateFlow init { - // Use to save compatibility with screens that using Redux states - bootstrapScreenState() - val isWalletConnectAvailable = runBlocking { // danger region, this works immediately, but will be refactored later with WC checkIsWalletConnectAvailableUseCase(params.userWalletId).getOrElse { throwable -> @@ -122,10 +114,6 @@ internal class DetailsModel @Inject constructor( .launchIn(modelScope) } - private fun bootstrapScreenState() { - appStateHolder.dispatch(LegacyAction.PrepareDetailsScreen) - } - private fun sendFeedback() { modelScope.launch { val userWallets = getWalletsUseCase.invokeSync() @@ -279,14 +267,5 @@ internal class DetailsModel @Inject constructor( } } - private fun getAppVersion(): String = "${appVersionProvider.versionName} (${appVersionProvider.versionCode})" - - private companion object { - val SYSTEM_LANGUAGE = runCatching { Resources.getSystem().configuration.locales[0].language }.getOrElse { "" } - val APP_LANGUAGE = Locale.getDefault().language - val UTM_MARKS = "utm_source=tangem-app" + - "&utm_medium=app" + - "&utm_campaign=users-$SYSTEM_LANGUAGE" + - "&utm_content=devicelang-$APP_LANGUAGE" - } + private fun getAppVersion(): String = "${appInfoProvider.appVersion} (${appInfoProvider.appVersionCode})" } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index 5c00ab323d..ac74369033 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -19,9 +19,8 @@ import com.tangem.domain.wallets.usecase.UnlockWalletUseCase import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.entity.WalletReorderUM import com.tangem.features.details.impl.R +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.utils.UserWalletSaver -import com.tangem.features.hotwallet.HotWalletFeatureToggles -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList @@ -40,14 +39,15 @@ internal class UserWalletListModel @Inject constructor( private val messageSender: UiMessageSender, override val dispatchers: CoroutineDispatcherProvider, private val userWalletSaver: UserWalletSaver, - private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, private val unlockWalletUseCase: UnlockWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, - private val walletFeatureToggles: WalletFeatureToggles, private val applyUserWalletListSortingUseCase: ApplyUserWalletListSortingUseCase, ) : Model() { private val isWalletSavingInProgress: MutableStateFlow = MutableStateFlow(value = false) + private val isWalletCreationRestrictionEnabled: StateFlow = + hotWalletRestrictionManager.isCreationEnabled() private val userWalletsFetcher = userWalletsFetcherFactory.create( messageSender = messageSender, onlyMultiCurrency = false, @@ -91,7 +91,7 @@ internal class UserWalletListModel @Inject constructor( isWalletSavingInProgress = isWalletSavingInProgress, addNewWalletText = resourceReference(R.string.user_wallet_list_add_button), walletReorderUM = WalletReorderUM( - isDragEnabled = walletFeatureToggles.isWalletReorderFeatureEnabled && userWallets.size > 1, + isDragEnabled = userWallets.size > 1, onMove = ::onWalletReorder, onDragStopped = ::onWalletDragStopped, ), @@ -101,7 +101,7 @@ internal class UserWalletListModel @Inject constructor( private fun onAddNewWalletClick() { analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.Settings)) - if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled) { + if (isWalletCreationRestrictionEnabled.value) { withProgress(isWalletSavingInProgress) { userWalletSaver.scanAndSaveUserWallet(modelScope) } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index 9746070c35..7bd5b7bd35 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -6,9 +6,9 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.impl.R -import com.tangem.features.hotwallet.HotWalletFeatureToggles import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -20,7 +20,7 @@ private const val TANGEM_PAY_ITEM_ID = "get_tangem_pay" @ModelScoped internal class ItemsBuilder @Inject constructor( private val router: Router, - private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, ) { @Suppress("LongParameterList") @@ -36,7 +36,7 @@ internal class ItemsBuilder @Inject constructor( buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add) buildUserWalletListBlock().let(::add) - if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled && hasAnyMobileWallet) { + if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) { DetailsItemUM.UnderSectionText( id = "only_one_mobile_wallet_explanation", text = resourceReference(R.string.only_one_mobile_wallet_explanation), diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt index d91257187b..b72e34a848 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt @@ -21,10 +21,10 @@ import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.common.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.details.impl.R +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -37,9 +37,9 @@ internal class UserWalletSaver @Inject constructor( private val scanCardProcessor: ScanCardProcessor, private val saveWalletUseCase: SaveWalletUseCase, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, - private val reduxStateHolder: ReduxStateHolder, private val messageSender: UiMessageSender, private val router: Router, + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) { suspend fun scanAndSaveUserWallet(scope: CoroutineScope) { @@ -57,7 +57,7 @@ internal class UserWalletSaver @Inject constructor( block = { scanResponse ?: return val userWallet = createUserWallet(scanResponse) - saveWallet(userWallet) + saveWallet(userWallet, scanResponse) }, recover = { val message = it.message @@ -71,7 +71,7 @@ internal class UserWalletSaver @Inject constructor( ) } - private suspend fun Raise.saveWallet(userWallet: UserWallet) { + private suspend fun Raise.saveWallet(userWallet: UserWallet, scanResponse: ScanResponse) { fold( block = { saveWalletUseCase( @@ -94,10 +94,19 @@ internal class UserWalletSaver @Inject constructor( } }, transform = { - // call only if wallet is successfully saved - reduxStateHolder.onUserWalletSelected(userWallet) - - router.popTo() + if (onboardingV2FeatureToggles.isAddressSyncEnabled) { + router.push( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWallet.walletId, + isWalletStarted = true, + ), + ), + ) + } else { + router.popTo() + } }, ) } diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt new file mode 100644 index 0000000000..614ff92ea4 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt @@ -0,0 +1,320 @@ +package com.tangem.features.details.utils + +import arrow.core.Either +import com.tangem.common.core.TangemError +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.common.wallets.error.SaveWalletError +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.details.impl.R +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class UserWalletSaverTest { + + private val scanCardProcessor: ScanCardProcessor = mockk() + private val saveWalletUseCase: SaveWalletUseCase = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk() + private val messageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val router: Router = mockk(relaxUnitFun = true) + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles = mockk() + + private val scanResponse: ScanResponse = mockk() + private val userWalletId: UserWalletId = UserWalletId("011") + private val userWallet: UserWallet.Cold = mockk { + every { walletId } returns userWalletId + } + + @BeforeEach + fun setUp() { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + } + + @Test + fun `GIVEN onWalletNotCreated WHEN scanAndSaveUserWallet THEN no message AND no save`() = runTest { + mockScanCallback(callbackName = ON_WALLET_NOT_CREATED) + + createSaver().scanAndSaveUserWallet(this) + + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onCancel WHEN scanAndSaveUserWallet THEN no message AND no save`() = runTest { + mockScanCallback(callbackName = ON_CANCEL) + + createSaver().scanAndSaveUserWallet(this) + + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onFailure with messageResId WHEN scanAndSaveUserWallet THEN SnackbarMessage with resource is sent`() = + runTest { + val tangemError = mockk { + every { silent } returns false + every { messageResId } returns R.string.common_unknown_error + every { customMessage } returns "any" + } + mockScanFailure(tangemError) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onFailure without messageResId WHEN scanAndSaveUserWallet THEN SnackbarMessage with custom message`() = + runTest { + val tangemError = mockk { + every { silent } returns false + every { messageResId } returns null + every { customMessage } returns "Custom error" + } + mockScanFailure(tangemError) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = stringReference("Custom error")), + ) + } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onFailure silent WHEN scanAndSaveUserWallet THEN no message is sent`() = runTest { + val tangemError = mockk { + every { silent } returns true + every { messageResId } returns null + every { customMessage } returns "any" + } + mockScanFailure(tangemError) + + createSaver().scanAndSaveUserWallet(this) + + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onSuccess AND save success AND addressSync disabled WHEN scanAndSaveUserWallet THEN popTo Wallet`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Right(Unit) + + createSaver().scanAndSaveUserWallet(this) + + verify { router.popTo(routeClass = AppRoute.Wallet::class, onComplete = any()) } + verify(exactly = 0) { router.push(route = any(), onComplete = any()) } + verify(exactly = 0) { messageSender.send(any()) } + } + + @Test + fun `GIVEN onSuccess AND save success AND addressSync enabled WHEN scanAndSaveUserWallet THEN push Onboarding`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns true + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Right(Unit) + + createSaver().scanAndSaveUserWallet(this) + + verify { + router.push( + route = AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWalletId, + isWalletStarted = true, + ), + ), + onComplete = any(), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + } + + @Test + fun `GIVEN onSuccess AND createUserWallet returns null WHEN scanAndSaveUserWallet THEN unknown error message`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(null) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onSuccess AND save WalletAlreadySaved WHEN scanAndSaveUserWallet THEN DialogMessage is sent`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left( + SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved), + ) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = DialogMessage( + message = resourceReference(R.string.user_wallet_list_error_wallet_already_saved), + ), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + verify(exactly = 0) { router.push(route = any(), onComplete = any()) } + } + + @Test + fun `GIVEN onSuccess AND save DataError with messageId WHEN scanAndSaveUserWallet THEN snackbar with resource`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left( + SaveWalletError.DataError(messageId = R.string.common_unknown_error), + ) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + } + + @Test + fun `GIVEN onSuccess AND save DataError without messageId WHEN scanAndSaveUserWallet THEN unknown error snackbar`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left( + SaveWalletError.DataError(messageId = null), + ) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + } + + private fun mockScanCallback(callbackName: String) { + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any(), + ) + } coAnswers { + when (callbackName) { + ON_WALLET_NOT_CREATED -> arg Unit>(ON_WALLET_NOT_CREATED_INDEX).invoke() + ON_CANCEL -> arg Unit>(ON_CANCEL_INDEX).invoke() + } + } + } + + private fun mockScanFailure(tangemError: TangemError) { + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any(), + ) + } coAnswers { + arg Unit>(ON_FAILURE_INDEX).invoke(tangemError) + } + } + + private fun mockScanSuccess(scanResponse: ScanResponse) { + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any(), + ) + } coAnswers { + arg Unit>(ON_SUCCESS_INDEX).invoke(scanResponse) + } + } + + private fun mockBuilderReturns(userWallet: UserWallet.Cold?) { + val builder: ColdUserWalletBuilder = mockk { + every { build() } returns userWallet + } + every { coldUserWalletBuilderFactory.create(scanResponse = any()) } returns builder + } + + private fun createSaver(): UserWalletSaver { + return UserWalletSaver( + scanCardProcessor = scanCardProcessor, + saveWalletUseCase = saveWalletUseCase, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + messageSender = messageSender, + router = router, + onboardingV2FeatureToggles = onboardingV2FeatureToggles, + ) + } + + private companion object { + const val ON_WALLET_NOT_CREATED = "onWalletNotCreated" + const val ON_CANCEL = "onCancel" + + const val ON_WALLET_NOT_CREATED_INDEX = 4 + const val ON_CANCEL_INDEX = 5 + const val ON_FAILURE_INDEX = 6 + const val ON_SUCCESS_INDEX = 7 + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt deleted file mode 100644 index 5a1dabaa43..0000000000 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.domain.models.currency.CryptoCurrency - -interface AddToPortfolioComponent : ComposableBottomSheetComponent { - - data class Params( - val addToPortfolioManager: AddToPortfolioManager, - val callback: Callback, - val shouldSkipTokenActionsScreen: Boolean = false, - ) - - interface Callback { - fun onDismiss() - // todo swap add new onSuccess with full data of added token - fun onSuccess(addedToken: CryptoCurrency) - } - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt deleted file mode 100644 index 50b323e258..0000000000 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add - -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams -import com.tangem.features.account.PortfolioFetcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.Serializable - -interface AddToPortfolioManager { - - // todo swap make updatable - val token: TokenMarketParams - val analyticsParams: AnalyticsParams? - val portfolioFetcher: PortfolioFetcher - - val state: StateFlow - - val allAvailableNetworks: Flow> - fun setTokenNetworks(networks: List) - - sealed interface State { - data object Init : State - data class AvailableToAdd( - val availableToAddData: AvailableToAddData, - ) : State - - data object NothingToAdd : State - } - - @Serializable - data class AnalyticsParams( - val source: String, - ) - - interface Factory { - fun create( - scope: CoroutineScope, - token: TokenMarketParams, - analyticsParams: AnalyticsParams?, - ): AddToPortfolioManager - } -} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt deleted file mode 100644 index c7feac3a8c..0000000000 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.serialization.Serializable - -interface AddToPortfolioPreselectedDataComponent : ComposableBottomSheetComponent { - - /** - * @param tokenToAdd token and preselected network (no network selector). - * @param callback callbacks for add-to-portfolio flow. - */ - data class Params( - val tokenToAdd: TokenToAdd, - val callback: Callback, - val analyticsParams: AnalyticsParams, - ) - - data class AnalyticsParams(val source: String) - - interface Callback { - fun onDismiss() - fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) - } - - @Serializable - data class TokenToAdd( - val network: TokenMarketInfo.Network, - val id: CryptoCurrency.RawID, - val name: String, - val symbol: String, - ) - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt index 7c9ffb7c0a..098b29f4da 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -1,6 +1,9 @@ package com.tangem.features.feed.entry.components import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import kotlinx.serialization.Serializable @@ -13,6 +16,9 @@ sealed interface FeedEntryRoute { val appCurrency: AppCurrency, val shouldShowPortfolio: Boolean, val analyticsParams: AnalyticsParams? = null, + val preselectedSection: PreselectedTokenDetailsSection? = null, + val shouldOpenExchanges: Boolean = false, + val exchangesCount: Int? = null, ) : FeedEntryRoute { @Serializable @@ -23,8 +29,14 @@ sealed interface FeedEntryRoute { } @Serializable - data object MarketTokenList : FeedEntryRoute + data class MarketTokenList( + val preselectedOrder: PreselectedMarketsOrder? = null, + val preselectedInterval: PreselectedMarketsInterval? = null, + ) : FeedEntryRoute @Serializable data class NewsDetail(val articleId: Int, val preselectedArticlesId: List) : FeedEntryRoute + + @Serializable + data class NewsList(val preselectedCategoryId: Int? = null) : FeedEntryRoute } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt index 5909a4876a..4cd535a841 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt @@ -3,6 +3,6 @@ package com.tangem.features.feed.entry.deeplink interface MarketsDeepLinkHandler { interface Factory { - fun create(): MarketsDeepLinkHandler + fun create(params: Map): MarketsDeepLinkHandler } } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsTokenExchangesDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsTokenExchangesDeepLinkHandler.kt new file mode 100644 index 0000000000..1ebdae4226 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsTokenExchangesDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.entry.deeplink + +import kotlinx.coroutines.CoroutineScope + +interface MarketsTokenExchangesDeepLinkHandler { + + interface Factory { + fun create(coroutineScope: CoroutineScope, params: Map): MarketsTokenExchangesDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDeepLinkHandler.kt new file mode 100644 index 0000000000..4beed492b5 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.entry.deeplink + +interface NewsDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): NewsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt deleted file mode 100644 index 1513ec7932..0000000000 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.feed.entry.featuretoggle - -interface FeedFeatureToggle { - val isEarnBlockEnabled: Boolean -} \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 41a5739506..7d4dc84f32 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -9,7 +9,7 @@ plugins { android { namespace = "com.tangem.features.feed.impl" - + packaging { resources { merges += "paymentrequest.proto" @@ -17,6 +17,10 @@ android { } } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /* Project - API */ api(projects.features.feed.api) @@ -25,6 +29,7 @@ dependencies { api(projects.features.tokenRecieve.api) api(projects.features.wallet.api) api(projects.features.account.api) + api(projects.features.commonFeatures.api) implementation(projects.features.promoBanners.api) /* Data */ @@ -101,4 +106,10 @@ dependencies { /** Tangem libraries */ implementation(tangemDeps.card.core) implementation(tangemDeps.blockchain) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 48d74bc9f4..198cdac711 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -29,6 +29,9 @@ import com.tangem.features.feed.entry.components.FeedEntryComponent import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.feed.model.FeedEntryModel import com.tangem.features.feed.model.feed.FeedModelClickIntents +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder +import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.EntryContent import dagger.assisted.Assisted @@ -87,6 +90,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( route = FeedEntryChildFactory.Child.TokenList( params = DefaultMarketsTokenListComponent.Params( preselectedSortType = sortBy ?: SortByTypeUM.Rating, + preselectedInterval = MarketsListUM.TrendInterval.H24, shouldAlwaysShowSearchBar = sortBy == null, ), ), @@ -121,15 +125,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } override fun onOpenAllNews() { - innerRouter.push(FeedEntryChildFactory.Child.NewsList) + innerRouter.push(FeedEntryChildFactory.Child.NewsList()) } override fun onOpenEarnPage() { innerRouter.push(FeedEntryChildFactory.Child.Earn) } - override fun openSearch() { - innerRouter.push(FeedEntryChildFactory.Child.Search) + override fun openSearch(source: String) { + innerRouter.push(FeedEntryChildFactory.Child.Search(source)) } } @@ -231,11 +235,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( paginationConfig = null, ) }, + preselectedSection = entryRoute.preselectedSection, + shouldOpenExchanges = entryRoute.shouldOpenExchanges, + exchangesCount = entryRoute.exchangesCount, ), ) - FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( + is FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( DefaultMarketsTokenListComponent.Params( - preselectedSortType = SortByTypeUM.Rating, + preselectedSortType = mapOrderToSortType(entryRoute.preselectedOrder), + preselectedInterval = mapIntervalToTrendInterval(entryRoute.preselectedInterval), shouldAlwaysShowSearchBar = false, ), ) @@ -255,6 +263,9 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( }, ), ) + is FeedEntryRoute.NewsList -> FeedEntryChildFactory.Child.NewsList( + preselectedCategoryId = entryRoute.preselectedCategoryId, + ) null -> FeedEntryChildFactory.Child.Feed } } @@ -265,4 +276,24 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } } +private fun mapOrderToSortType(order: PreselectedMarketsOrder?): SortByTypeUM { + return when (order) { + PreselectedMarketsOrder.Rating -> SortByTypeUM.Rating + PreselectedMarketsOrder.Trending -> SortByTypeUM.Trending + PreselectedMarketsOrder.Buyers -> SortByTypeUM.ExperiencedBuyers + PreselectedMarketsOrder.Gainers -> SortByTypeUM.TopGainers + PreselectedMarketsOrder.Losers -> SortByTypeUM.TopLosers + null -> SortByTypeUM.Rating + } +} + +private fun mapIntervalToTrendInterval(interval: PreselectedMarketsInterval?): MarketsListUM.TrendInterval { + return when (interval) { + PreselectedMarketsInterval.H24 -> MarketsListUM.TrendInterval.H24 + PreselectedMarketsInterval.W1 -> MarketsListUM.TrendInterval.D7 + PreselectedMarketsInterval.D30 -> MarketsListUM.TrendInterval.M1 + null -> MarketsListUM.TrendInterval.H24 + } +} + internal interface FeedEntryClickIntents : FeedModelClickIntents \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index a51d8689f7..fb8c3a7f90 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -11,7 +11,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent @@ -29,7 +29,7 @@ internal class FeedEntryChildFactory @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val portfolioComponentFactory: MarketsPortfolioComponent.Factory, private val portfolioBlockComponentFactory: PortfolioBlockComponent.Factory, - private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, private val designFeatureToggles: DesignFeatureToggles, @@ -53,7 +53,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data object NewsList : Child + data class NewsList(val preselectedCategoryId: Int? = null) : Child @Serializable @Immutable @@ -65,7 +65,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data object Search : Child + data class Search(val source: String) : Child } @Suppress("LongMethod") @@ -84,6 +84,7 @@ internal class FeedEntryChildFactory @Inject constructor( portfolioComponentFactory = portfolioComponentFactory, portfolioBlockComponentFactory = portfolioBlockComponentFactory, designFeatureToggles = designFeatureToggles, + addToPortfolioComponentFactory = addToPortfolioComponentFactory, ) } is Child.TokenList -> { @@ -109,7 +110,7 @@ internal class FeedEntryChildFactory @Inject constructor( params = child.params, ) } - Child.NewsList -> { + is Child.NewsList -> { DefaultNewsListComponent( appComponentContext = appComponentContext, params = Params( @@ -122,6 +123,7 @@ internal class FeedEntryChildFactory @Inject constructor( ) }, onBackClick = onBackClicked, + preselectedCategoryId = child.preselectedCategoryId, ), ) } @@ -129,7 +131,7 @@ internal class FeedEntryChildFactory @Inject constructor( DefaultFeedComponent( appComponentContext = appComponentContext, params = FeedParams(feedClickIntents = feedEntryClickIntents), - addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, + addToPortfolioComponentFactory = addToPortfolioComponentFactory, promoBannersBlockComponentFactory = promoBannersBlockComponentFactory, newPromoBannersFeatureToggles = newPromoBannersFeatureToggles, ) @@ -141,13 +143,21 @@ internal class FeedEntryChildFactory @Inject constructor( onBackClick = onBackClicked, onSearchClicked = feedEntryClickIntents::openSearch, ), - addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, + addToPortfolioComponentFactory = addToPortfolioComponentFactory, ) } - Child.Search -> DefaultSearchComponent( + is Child.Search -> DefaultSearchComponent( appComponentContext = appComponentContext, params = DefaultSearchComponent.Params( onBackClick = onBackClicked, + onMarketTokenClick = { token, currency -> + feedEntryClickIntents.onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.Market.value, + ) + }, + sourceParams = child.source, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index ac8f5fffdc..bdef4f7ffe 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -33,9 +33,10 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.EarnModel +import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.earn.EarnContent import dev.chrisbanes.haze.HazeProgressive @@ -43,7 +44,7 @@ import dev.chrisbanes.haze.HazeProgressive internal class DefaultEarnComponent( appComponentContext: AppComponentContext, private val params: Params, - private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { private val earnModel = getOrCreateModel(params = params) @@ -129,10 +130,14 @@ internal class DefaultEarnComponent( is FeedBottomSheetRoute.AddToPortfolio -> { addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = AddToPortfolioPreselectedDataComponent.Params( - tokenToAdd = config.tokenToAdd, - callback = earnModel.addToPortfolioCallback, - analyticsParams = AddToPortfolioPreselectedDataComponent.AnalyticsParams(config.source), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = when { + config.source == EarnSource.BEST_OPPORTUNITIES_SOURCE.value -> + earnModel.addBestOpportunitiesPortfolioManager + config.source == EarnSource.MOSTLY_USED_SOURCE.value -> + earnModel.addMostlyUsedPortfolioManager + else -> error("Unknown source: ${config.source}") + }, ), ) } @@ -148,6 +153,6 @@ internal class DefaultEarnComponent( data class Params( val onBackClick: () -> Unit, - val onSearchClicked: () -> Unit, + val onSearchClicked: (source: String) -> Unit, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index a374e0faee..21ebee8140 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot -import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext @@ -20,8 +19,7 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.ui.feed.FeedList @@ -32,7 +30,7 @@ import com.tangem.features.promobanners.api.PromoBannersBlockComponent internal class DefaultFeedComponent( appComponentContext: AppComponentContext, private val params: FeedParams, - private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { @@ -102,12 +100,8 @@ internal class DefaultFeedComponent( is FeedBottomSheetRoute.AddToPortfolio -> { addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = Params( - tokenToAdd = config.tokenToAdd, - callback = feedComponentModel.addToPortfolioCallback, - analyticsParams = AddToPortfolioPreselectedDataComponent.AnalyticsParams( - AnalyticsParam.ScreensSources.Markets.value, - ), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = feedComponentModel.addToPortfolioManager, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt index fea9189d03..fc72c74f86 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt @@ -2,12 +2,10 @@ package com.tangem.features.feed.components.feed import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent internal sealed interface FeedBottomSheetRoute { data class AddToPortfolio( - val tokenToAdd: AddToPortfolioPreselectedDataComponent.TokenToAdd, val source: String, ) : FeedBottomSheetRoute diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/AddToPortfolioSlotRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/AddToPortfolioSlotRoute.kt new file mode 100644 index 0000000000..fc1cfda981 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/AddToPortfolioSlotRoute.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.components.market.details + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal data object AddToPortfolioSlotRoute : Route \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index a9c1b09117..cb37e92734 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -16,15 +16,20 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType @@ -33,10 +38,13 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent +import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.state.TokenNetworksState @@ -47,13 +55,15 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +@Suppress("LongParameterList") internal class DefaultMarketsTokenDetailsComponent( appComponentContext: AppComponentContext, - val params: Params, analyticsEventHandler: AnalyticsEventHandler, designFeatureToggles: DesignFeatureToggles, portfolioComponentFactory: MarketsPortfolioComponent.Factory, portfolioBlockComponentFactory: PortfolioBlockComponent.Factory, + val params: Params, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { // applying l2 compatibility @@ -82,12 +92,28 @@ internal class DefaultMarketsTokenDetailsComponent( if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) { portfolioBlockComponentFactory.create( context = child("portfolio_block"), - params = PortfolioBlockComponent.Params(updatedParams.token), + params = PortfolioBlockComponent.Params(token = updatedParams.token), + parentRouter = object : PortfolioBlockParentClickIntents { + override fun openAddToPortfolioDirect() { + model.openAddToPortfolio() + } + + override fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) { + model.openAddToPortfolioViaUserPortfolio() + } + }, ) } else { null } + private val addToPortfolioSlot = childSlot( + source = model.addToPortfolioSheetNavigation, + serializer = AddToPortfolioSlotRoute.serializer(), + handleBackButton = false, + childFactory = ::addToPortfolioChild, + ) + init { componentScope.launch(dispatchers.default) { model.networksState.collectLatest { state -> @@ -119,6 +145,16 @@ internal class DefaultMarketsTokenDetailsComponent( } } + private fun addToPortfolioChild( + @Suppress("UNUSED_PARAMETER") config: AddToPortfolioSlotRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + return addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params(addToPortfolioManager = model.addToPortfolioManager), + ) + } + @Composable override fun Title(bottomSheetState: State) { val state by model.state.collectAsStateWithLifecycle() @@ -197,6 +233,7 @@ internal class DefaultMarketsTokenDetailsComponent( } } val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by addToPortfolioSlot.subscribeAsState() val bsState by bottomSheetState LaunchedEffect(bsState) { model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED @@ -218,6 +255,7 @@ internal class DefaultMarketsTokenDetailsComponent( } }, ) + bottomSheet.child?.instance?.BottomSheet() } @Serializable @@ -228,6 +266,9 @@ internal class DefaultMarketsTokenDetailsComponent( val analyticsParams: AnalyticsParams?, val onBackClicked: () -> Unit, val onArticleClick: (articleId: Int, preselectedArticlesId: List) -> Unit, + val preselectedSection: PreselectedTokenDetailsSection? = null, + val shouldOpenExchanges: Boolean = false, + val exchangesCount: Int? = null, ) @Serializable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt deleted file mode 100644 index 95efde9298..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl - -import androidx.compose.runtime.Composable -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.stack.backStack -import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.pop -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioPreselectedDataModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted private val params: AddToPortfolioPreselectedDataComponent.Params, - portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, - addTokenComponentFactory: AddTokenComponent.Factory, -) : AppComponentContext by context, AddToPortfolioPreselectedDataComponent { - - private val model: AddToPortfolioPreselectedDataModel = getOrCreateModel(params) - - private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create( - context = child("portfolioSelectorComponent"), - params = PortfolioSelectorComponent.Params( - portfolioFetcher = model.portfolioFetcher, - controller = model.portfolioSelectorController, - ), - ) - - private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( - context = child("addTokenComponent"), - params = AddTokenComponent.Params( - eventBuilder = model.eventBuilder, - callbacks = model, - selectedPortfolio = model.selectedPortfolio, - selectedNetwork = model.selectedNetwork, - ), - ) - - private val childStack = childStack( - key = "addToPortfolioFromEarnStack", - handleBackButton = true, - source = model.navigation, - serializer = AddToPortfolioRoutes.serializer(), - initialStack = { model.currentStack }, - childFactory = { configuration, _ -> - contentChild(configuration) - }, - ) - - private fun onBack() { - if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss() - } - - override fun dismiss() { - params.callback.onDismiss() - } - - @Composable - override fun BottomSheet() { - AddToPortfolioBottomSheet( - childStack = childStack.subscribeAsState(), - onBack = ::onBack, - onDismiss = ::dismiss, - ) - } - - private fun contentChild(config: AddToPortfolioRoutes): ComposableContentComponent = when (config) { - AddToPortfolioRoutes.AddToken -> addTokenComponent - AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent - AddToPortfolioRoutes.TokenActions -> ComposableContentComponent.EMPTY - AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY - is AddToPortfolioRoutes.NetworkSelector -> ComposableContentComponent.EMPTY - } - - @AssistedFactory - interface Factory : AddToPortfolioPreselectedDataComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddToPortfolioPreselectedDataComponent.Params, - ): DefaultAddToPortfolioPreselectedDataComponent - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt deleted file mode 100644 index 1233c2be6a..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.di - -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -internal interface AddToPortfolioComponentModule { - - @Binds - fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory - - @Binds - fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory - - @Binds - fun bindAddToPortfolioPreselectedDataComponent( - factory: DefaultAddToPortfolioPreselectedDataComponent.Factory, - ): AddToPortfolioPreselectedDataComponent.Factory -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt deleted file mode 100644 index 711c38b1f3..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt +++ /dev/null @@ -1,389 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model - -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.popToFirst -import com.arkivanov.decompose.router.stack.pushNew -import com.arkivanov.decompose.router.stack.replaceAll -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.ToastMessage -import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 -import com.tangem.domain.markets.GetTokenMarketCryptoCurrency -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.account.PortfolioSelectorController -import com.tangem.features.feed.components.market.details.portfolio.add.* -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData -import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions -import com.tangem.features.feed.impl.R -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import javax.inject.Inject - -private const val TOKEN_ACTIONS_DELAY = 500L - -@ModelScoped -@Suppress("LongParameterList") -internal class AddToPortfolioModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, - private val callbackDelegate: AddToPortfolioCallbackDelegate, - private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, - private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, - private val messageSender: UiMessageSender, - private val analyticsEventHandler: AnalyticsEventHandler, - val portfolioSelectorController: PortfolioSelectorController, -) : Model(), - ChooseNetworkComponent.Callbacks by callbackDelegate, - TokenActionsComponent.Callbacks by callbackDelegate, - AddTokenComponent.Callbacks by callbackDelegate { - - private val params = paramsContainer.require() - val navigation = StackNavigation() - var currentStack = listOf(AddToPortfolioRoutes.Empty) - - /* Flows that hold state and provide it to child models */ - val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() - val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() - val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() - - private val addToPortfolioManager = params.addToPortfolioManager - val portfolioFetcher = addToPortfolioManager.portfolioFetcher - val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( - tokenSymbol = addToPortfolioManager.token.symbol, - source = addToPortfolioManager.analyticsParams?.source, - ) - - val featureData: Flow = combineFeatureData() - - init { - navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } - startAddToPortfolioFlow() - } - - private fun replayMutableSharedFlow() = MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - @Suppress("LongMethod") - private fun startAddToPortfolioFlow() { - channelFlow { - fun finishFlow() { - params.callback.onDismiss() - channel.close() - } - - fun finishSuccessFlow(addedToken: CryptoCurrencyStatus) { - params.callback.onSuccess(addedToken.currency) - channel.close() - } - - val featureDataFlow: StateFlow = featureData - .filterIsInstance() - .map { it.availableToAddData } - .distinctUntilChanged() - .stateIn(this) - val isAccountMode = portfolioSelectorController.isAccountModeSync() - - // use snapshot data, looks like we don’t need to remap at runtime - val data = featureDataFlow.value - - // you must control it via [AddToPortfolioManager.state] - if (!data.isAvailableToAdd) { - finishFlow() - return@channelFlow - } - - // init data flows, emits on user/code selection, updates state holder - val firstSelectedPortfolio = setupPortfolioFlow(data) - .onEach { selectedPortfolio.emit(it) } - val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio) - .onEach { selectedNetwork.emit(it) } - - val isSinglePortfolio = data.isSinglePortfolio - if (isSinglePortfolio) { - val accountId = data.availableToAddWallets.values.first() - .availableToAddAccounts.values.first() - .account.account.accountId - // force select a portfolio, triggers [selectedPortfolio] - portfolioSelectorController.selectAccount(accountId) - } else { - logAccountSelector(isAccountMode) - navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) - } - - val firstPartOfNavigation: Job = firstSelectedPortfolio - .onEach { portfolio -> - val isSingleAvailableNetwork = portfolio.account.isSingleNetwork - when { - // force select a network, triggers [selectedNetwork] - isSingleAvailableNetwork -> { - val singleNetwork = portfolio.account.availableToAddNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) - } - // it's important to control root screen, UI depends on it(close/arrow icon) - isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) - else -> navigation.pushNew(routeToNetworkSelector(portfolio)) - } - } - .launchIn(this) - - // main flow that combine all require data - val allRequireForAdd = combine( - flow = firstSelectedNetwork, - flow2 = firstSelectedPortfolio, - transform = { a, b -> a to b }, - ) - - // suspend until all required data is selected - allRequireForAdd.first() - // line of navigation to AddToken screen is finished; cancel the job, select a new root screen - firstPartOfNavigation.cancel() - - analyticsEventHandler.send(event = eventBuilder.popupToConfirm()) - navigation.replaceAll(AddToPortfolioRoutes.AddToken) - - var middleNavigationJob: Job? = null - // handle actions from AddToken screen - callbackDelegate.onChangeNetworkClick.receiveAsFlow() - .onEach { - middleNavigationJob?.cancel() - middleNavigationJob = changeNetworkNavigationFlow() - .launchIn(this) - val route = routeToNetworkSelector(selectedPortfolio.first()) - navigation.pushNew(route) - } - .launchIn(this) - // handle actions from AddToken screen - callbackDelegate.onChangePortfolioClick.receiveAsFlow() - .onEach { - middleNavigationJob?.cancel() - middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this) - logAccountSelector(isAccountMode) - navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) - } - .launchIn(this) - - // suspend until token is added - val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() - middleNavigationJob?.cancel() - val selectedPortfolio = selectedPortfolio.first() - - messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) - - if (params.shouldSkipTokenActionsScreen) { - finishSuccessFlow(addedToken) - } else { - setupTokenActionsFlow(selectedPortfolio, addedToken) - .onEach { cryptoCurrencyData -> - tokenActionsData.emit(cryptoCurrencyData) - navigation.replaceAll(AddToPortfolioRoutes.TokenActions) - } - .onEmpty { finishFlow() } - .launchIn(this) - } - - callbackDelegate.onLaterClick.receiveAsFlow().first() - finishFlow() - } - .catch { throwable -> - TangemLogger.e("Error", throwable) - params.callback.onDismiss() - } - .launchIn(modelScope) - } - - private fun logAccountSelector(isAccountMode: Boolean) { - if (isAccountMode) { - analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) - } - } - - private fun changeNetworkNavigationFlow(): Flow { - return setupNetworkFlow(selectedPortfolio) - .onEach { newNetwork -> - selectedNetwork.emit(newNetwork) - navigation.popToFirst() - } - } - - private fun changePortfolioNavigationFlow(data: AvailableToAddData): Flow = flow { - val selectedPortfolioValue = selectedPortfolio.first() - val selectedAccount = selectedPortfolioValue.account.account.account.accountId - portfolioSelectorController.selectAccount(selectedAccount) - val changedPortfolio = setupPortfolioFlow(data) - .drop(1) - .onEach { portfolio -> - val isSingleAvailableNetwork = portfolio.account.isSingleNetwork - if (isSingleAvailableNetwork) { - val singleNetwork = portfolio.account.availableToAddNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) - } else { - navigation.pushNew(routeToNetworkSelector(portfolio)) - } - } - val changedNetwork = setupNetworkFlow(changedPortfolio) - combine( - flow = changedPortfolio, - flow2 = changedNetwork, - transform = { newPortfolio, newNetwork -> - selectedPortfolio.tryEmit(newPortfolio) - selectedNetwork.tryEmit(newNetwork) - navigation.popToFirst() - }, - ).collect { emit(it) } - } - - private fun setupTokenActionsFlow( - selectedPortfolio: SelectedPortfolio, - addedToken: CryptoCurrencyStatus, - ): Flow { - val timeFlow = channelFlow { - val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } - getCryptoCurrencyActionsUseCase( - currency = addedToken.currency, - accountId = selectedPortfolio.account.account.account.accountId, - ).onEach { state -> - val requestedQuickActions = toQuickActions(state.states) - when { - requestedQuickActions.isNotEmpty() -> { - timerJob.cancel() - send(state) - } - // wait any requestedQuickActions while timer active - timerJob.isActive -> Unit - else -> close() - } - }.collect() - } - return timeFlow.map { actionsState -> - PortfolioData.CryptoCurrencyData( - userWallet = selectedPortfolio.userWallet, - status = actionsState.cryptoCurrencyStatus, - actions = actionsState.states, - ) - } - } - - private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( - flow = portfolioSelectorController.isAccountMode, - flow2 = portfolioSelectorController.selectedAccount, - transform = { isAccountMode, selectedAccountId -> - selectedAccountId ?: return@combine null - val availableToAddWallets = - data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null - val availableToAddAccount = - availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null - if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) - SelectedPortfolio( - isAccountMode = isAccountMode, - userWallet = availableToAddWallets.userWallet, - account = availableToAddAccount, - isAvailableMorePortfolio = !data.isSinglePortfolio, - ) - }, - ) - .filterNotNull() - - private fun setupNetworkFlow(selectedPortfolioFlow: Flow): Flow = combine( - flow = selectedPortfolioFlow, - flow2 = callbackDelegate.onNetworkSelected.receiveAsFlow(), - transform = transform@{ selectedPortfolio, selectedNetwork -> - SelectedNetwork( - cryptoCurrency = createCryptoCurrency( - userWallet = selectedPortfolio.userWallet, - network = selectedNetwork, - account = selectedPortfolio.account, - ) ?: return@transform null, - selectedNetwork = selectedNetwork, - isAvailableMoreNetwork = !selectedPortfolio.account.isSingleNetwork, - ) - }, - ) - .filterNotNull() - - private suspend fun createCryptoCurrency( - userWallet: UserWallet, - network: TokenMarketInfo.Network, - account: AvailableToAddAccount, - ): CryptoCurrency? { - val accountIndex = account.account.account.derivationIndex - return getTokenMarketCryptoCurrency( - userWalletId = userWallet.walletId, - tokenMarketParams = addToPortfolioManager.token, - network = network, - accountIndex = accountIndex, - ) - } - - private fun routeToNetworkSelector(portfolio: SelectedPortfolio): AddToPortfolioRoutes.NetworkSelector { - return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) - } - - private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> - when (state) { - is AddToPortfolioManager.State.AvailableToAdd -> - portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> - val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] - ?: return@isEnabled false - val isAvailableAccount = - availableWallet.availableToAddAccounts[accountStatus.account.accountId] - ?.isAvailableToAdd == true - return@isEnabled isAvailableAccount - } - AddToPortfolioManager.State.Init, - AddToPortfolioManager.State.NothingToAdd, - -> Unit - } - } -} - -@ModelScoped -internal class AddToPortfolioCallbackDelegate @Inject constructor() : - ChooseNetworkComponent.Callbacks, - TokenActionsComponent.Callbacks, - AddTokenComponent.Callbacks { - - val onNetworkSelected = Channel() - val onLaterClick = Channel() - val onChangeNetworkClick = Channel() - val onChangePortfolioClick = Channel() - val onTokenAdded = Channel() - - override fun onNetworkSelected(network: TokenMarketInfo.Network) { - onNetworkSelected.trySend(network) - } - - override fun onLaterClick() { - onLaterClick.trySend(Unit) - } - - override fun onChangeNetworkClick() { - onChangeNetworkClick.trySend(Unit) - } - - override fun onChangePortfolioClick() { - onChangePortfolioClick.trySend(Unit) - } - - override fun onTokenAdded(status: CryptoCurrencyStatus) { - onTokenAdded.trySend(status) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt deleted file mode 100644 index a28a4fd173..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt +++ /dev/null @@ -1,306 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model - -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.replaceAll -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.ToastMessage -import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.markets.GetTokenMarketCryptoCurrency -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorController -import com.tangem.features.feed.components.market.details.portfolio.add.* -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent -import com.tangem.features.feed.impl.R -import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* -import java.math.BigDecimal -import javax.inject.Inject - -@Suppress("LongParameterList") -internal class AddToPortfolioPreselectedDataModel @Inject constructor( - paramsContainer: ParamsContainer, - portfolioFetcherFactory: PortfolioFetcher.Factory, - override val dispatchers: CoroutineDispatcherProvider, - val portfolioSelectorController: PortfolioSelectorController, - private val callbackDelegate: AddToPortfolioFromEarnCallbackDelegate, - private val messageSender: UiMessageSender, - private val analyticsEventHandler: AnalyticsEventHandler, - private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, - private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, -) : Model(), AddTokenComponent.Callbacks by callbackDelegate { - - private val params = paramsContainer.require() - - val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( - mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), - scope = modelScope, - ) - - val navigation = StackNavigation() - var currentStack = listOf(AddToPortfolioRoutes.Empty) - - private val _selectedNetwork = MutableStateFlow(null) - val selectedNetwork: Flow = _selectedNetwork.asStateFlow().filterNotNull() - - private val _selectedPortfolio = MutableStateFlow(null) - val selectedPortfolio: Flow = _selectedPortfolio.asStateFlow().filterNotNull() - val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( - tokenSymbol = params.tokenToAdd.symbol, - source = AnalyticsParam.ScreensSources.Markets.value, - ) - - init { - navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } - startAddToPortfolioFlow() - } - - @Suppress("LongMethod") - private fun startAddToPortfolioFlow() { - channelFlow { - fun finishSuccessFlow(currency: CryptoCurrency, userWalletId: UserWalletId) { - params.callback.onSuccess(addedToken = currency, walletId = userWalletId) - channel.close() - } - - val data = createAvailableToAddDataForPreselectedNetwork(params.tokenToAdd.network) - ?: return@channelFlow - - val isAccountMode = portfolioSelectorController.isAccountModeSync() - val firstSelectedPortfolio = setupPortfolioFlow(data) - .onEach { _selectedPortfolio.value = it } - - val firstSelectedNetwork = firstSelectedPortfolio - .map { portfolio -> createSelectedNetwork(network = params.tokenToAdd.network, portfolio = portfolio) } - .filterNotNull() - .onEach { _selectedNetwork.value = it } - - val isSinglePortfolio = data.isSinglePortfolio - if (isSinglePortfolio) { - val accountId = data.availableToAddWallets.values.first() - .availableToAddAccounts.values.first() - .account.account.accountId - // force select a portfolio, triggers [selectedPortfolio] - portfolioSelectorController.selectAccount(accountId) - } else { - logAccountSelector(isAccountMode) - navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) - } - - // main flow that combine all require data - val allRequireForAdd = combine( - flow = firstSelectedNetwork, - flow2 = firstSelectedPortfolio, - transform = { a, b -> a to b }, - ) - - // suspend until all required data is selected - val (selectedNetworkValue, selectedPortfolioValue) = allRequireForAdd.first() - - val isTokenAlreadyAdded = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = selectedPortfolioValue.userWallet.walletId, - currency = selectedNetworkValue.cryptoCurrency, - ).isSome() - - if (isTokenAlreadyAdded) { - finishSuccessFlow( - currency = selectedNetworkValue.cryptoCurrency, - userWalletId = selectedPortfolioValue.userWallet.walletId, - ) - return@channelFlow - } - - sendAddTokenOpenedAnalytics(selectedNetworkValue.cryptoCurrency) - navigation.replaceAll(AddToPortfolioRoutes.AddToken) - val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() - messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) - sendSuccessAddedAnalytics(addedToken.currency) - finishSuccessFlow(addedToken.currency, selectedPortfolioValue.userWallet.walletId) - } - .catch { throwable -> - TangemLogger.e("Error", throwable) - params.callback.onDismiss() - } - .launchIn(modelScope) - } - - private fun logAccountSelector(isAccountMode: Boolean) { - if (isAccountMode) { - analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) - } - } - - private fun minimalTokenMarketParams() = with(params.tokenToAdd) { - TokenMarketParams( - id = id, - name = name, - symbol = symbol, - tokenQuotes = TokenMarketParams.Quotes( - currentPrice = BigDecimal.ZERO, - h24Percent = null, - weekPercent = null, - monthPercent = null, - ), - imageUrl = null, - ) - } - - /** - * Creates [AvailableToAddData] for preselected network when token is already added in all networks. - * This allows user to select wallet/account and do smth after it with selected info. - */ - private suspend fun createAvailableToAddDataForPreselectedNetwork( - preSelectedNetwork: TokenMarketInfo.Network, - ): AvailableToAddData? { - val portfolioData = portfolioFetcher.data.firstOrNull() ?: return null - - val availableToAddInWallets = portfolioData.balances.mapNotNull { (walletId, balance) -> - val wallet = balance.userWallet - val accounts = balance.accountsBalance.accountStatuses.filterCryptoPortfolio() - - val availableToAddAccounts = accounts.mapNotNull { accountStatus -> - val accountIndex = when (accountStatus) { - is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex - } - - val cryptoCurrency = getTokenMarketCryptoCurrency( - userWalletId = walletId, - tokenMarketParams = minimalTokenMarketParams(), - network = preSelectedNetwork, - accountIndex = accountIndex, - ) ?: return@mapNotNull null - - val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = walletId, - currency = cryptoCurrency, - ).fold( - ifEmpty = { emptySet() }, - ifSome = { setOf(it.status.currency.network) }, - ) - - AvailableToAddAccount( - account = accountStatus, - availableNetworks = setOf(preSelectedNetwork), - addedNetworks = addedNetworks, - ) - }.associateBy { it.account.account.accountId } - - if (availableToAddAccounts.isEmpty()) return@mapNotNull null - - walletId to AvailableToAddWallet( - userWallet = wallet, - accounts = accounts, - availableNetworks = setOf(preSelectedNetwork), - availableToAddAccounts = availableToAddAccounts, - ) - }.toMap() - - if (availableToAddInWallets.isEmpty()) return null - - return AvailableToAddData(availableToAddWallets = availableToAddInWallets) - } - - private suspend fun createCryptoCurrency( - userWallet: UserWallet, - network: TokenMarketInfo.Network, - account: AvailableToAddAccount, - ): CryptoCurrency? { - val accountIndex = when (val accountStatus = account.account) { - is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex - is AccountStatus.Payment -> return null - } - return getTokenMarketCryptoCurrency( - userWalletId = userWallet.walletId, - tokenMarketParams = minimalTokenMarketParams(), - network = network, - accountIndex = accountIndex, - ) - } - - private suspend fun createSelectedNetwork( - network: TokenMarketInfo.Network, - portfolio: SelectedPortfolio, - ): SelectedNetwork? { - val cryptoCurrency = createCryptoCurrency( - userWallet = portfolio.userWallet, - network = network, - account = portfolio.account, - ) ?: return null - - return SelectedNetwork( - cryptoCurrency = cryptoCurrency, - selectedNetwork = network, - isAvailableMoreNetwork = false, - ) - } - - private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( - flow = portfolioSelectorController.isAccountMode, - flow2 = portfolioSelectorController.selectedAccount, - transform = { isAccountMode, selectedAccountId -> - selectedAccountId ?: return@combine null - val availableToAddWallets = - data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null - val availableToAddAccount = - availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null - if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) - SelectedPortfolio( - isAccountMode = isAccountMode, - userWallet = availableToAddWallets.userWallet, - account = availableToAddAccount, - isAvailableMorePortfolio = false, - ) - }, - ) - .filterNotNull() - - private fun sendSuccessAddedAnalytics(cryptoCurrency: CryptoCurrency) { - analyticsEventHandler.send( - EarnAnalyticsEvent.TokenAdded( - tokenSymbol = cryptoCurrency.symbol, - blockchain = cryptoCurrency.network.name, - ), - ) - } - - private fun sendAddTokenOpenedAnalytics(cryptoCurrency: CryptoCurrency) { - analyticsEventHandler.send( - EarnAnalyticsEvent.AddTokenScreenOpened( - tokenSymbol = cryptoCurrency.symbol, - blockchain = cryptoCurrency.network.name, - source = params.analyticsParams.source, - ), - ) - } -} - -@ModelScoped -internal class AddToPortfolioFromEarnCallbackDelegate @Inject constructor() : - AddTokenComponent.Callbacks { - val onTokenAdded = Channel() - - override fun onChangeNetworkClick() = Unit - - override fun onChangePortfolioClick() = Unit - - override fun onTokenAdded(status: CryptoCurrencyStatus) { - onTokenAdded.trySend(status) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt deleted file mode 100644 index 8828010452..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model - -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.extensions.stringReference -import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData -import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter -import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler -import javax.inject.Inject - -@ModelScoped -internal class TokenActionsUiBuilder @Inject constructor( - paramsContainer: ParamsContainer, - private val analyticsEventHandler: AnalyticsEventHandler, -) { - private val params = paramsContainer.require() - - fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { - val status = data.status - val tokenUM = TokenItemState.Content( - id = status.currency.id.value, - iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), - titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), - fiatAmountState = null, - subtitle2State = null, - subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), - onItemClick = null, - onItemLongClick = null, - ) - return TokenActionsUM( - token = tokenUM, - onLaterClick = { - analyticsEventHandler.send(params.eventBuilder.getTokenLater()) - params.callbacks.onLaterClick() - }, - quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler), - ) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt deleted file mode 100644 index 7b92e78e3b..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.key -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.util.fastForEachIndexed -import com.tangem.core.ui.components.label.Label -import com.tangem.core.ui.components.label.entity.LabelStyle -import com.tangem.core.ui.components.label.entity.LabelUM -import com.tangem.core.ui.components.rows.BlockchainRow -import com.tangem.core.ui.components.rows.model.BlockchainRowUM -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM -import com.tangem.features.feed.impl.R -import kotlinx.collections.immutable.persistentListOf -import java.util.UUID - -private const val DISABLED_ALPHA = 0.4f - -@Composable -internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.action), - ) { - state.networks.fastForEachIndexed { index, model -> - key(model.id) { - BlockchainRow( - model = model, - itemPadding = PaddingValues( - horizontal = TangemTheme.dimens.spacing12, - vertical = TangemTheme.dimens.spacing14, - ), - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = model.isEnabled, onClick = { state.onNetworkClick(model) }), - ) { - if (!model.isEnabled) { - Label( - modifier = Modifier.alpha(DISABLED_ALPHA), - state = LabelUM( - text = resourceReference(R.string.common_added), - style = LabelStyle.REGULAR, - ), - ) - } - } - } - } - } -} - -@Composable -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { - TangemThemePreview { - ChooseNetworkContent( - state = content, - ) - } -} - -internal class ChooseNetworkContentProvider : PreviewParameterProvider { - - private val blockchainRow = BlockchainRowUM( - id = UUID.randomUUID().toString(), - name = "Etherium 3", - type = "TEST", - iconResId = R.drawable.img_eth_22, - isMainNetwork = false, - isSelected = true, - isEnabled = true, - ) - - override val values: Sequence - get() = sequenceOf( - ChooseNetworkUM( - onNetworkClick = {}, - networks = persistentListOf( - blockchainRow.copy( - type = "MAIN", - isMainNetwork = true, - ), - blockchainRow.copy( - iconResId = R.drawable.ic_bsc_16, - isEnabled = false, - ), - blockchainRow.copy(iconResId = R.drawable.img_polygon_22), - blockchainRow.copy(iconResId = R.drawable.img_optimism_22), - ), - ), - ) -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt deleted file mode 100644 index 8c27a3929d..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui - -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager -import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.* - -internal class DefaultAddToPortfolioManager @AssistedInject constructor( - private val availableToAddDataConverter: AvailableToAddDataConverter, - @Assisted override val token: TokenMarketParams, - @Assisted override val analyticsParams: AddToPortfolioManager.AnalyticsParams?, - @Assisted val scope: CoroutineScope, - dispatchers: CoroutineDispatcherProvider, - portfolioFetcherFactory: PortfolioFetcher.Factory, -) : AddToPortfolioManager { - - private val _allAvailableNetworks = MutableSharedFlow>( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - override val allAvailableNetworks: Flow> = _allAvailableNetworks.asSharedFlow() - override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( - mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), - scope = scope, - ) - - override val state: StateFlow = - combine( - flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), - flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(), - ) { balances, availableNetworks -> - val data = availableToAddDataConverter.convert( - balances = balances, - availableNetworks = availableNetworks, - marketParams = token, - ) - if (data.isAvailableToAdd) { - AddToPortfolioManager.State.AvailableToAdd(data) - } else { - AddToPortfolioManager.State.NothingToAdd - } - } - .flowOn(dispatchers.default) - .stateIn( - scope = scope, - started = SharingStarted.Eagerly, - initialValue = AddToPortfolioManager.State.Init, - ) - - override fun setTokenNetworks(networks: List) { - _allAvailableNetworks.tryEmit(networks) - } - - @AssistedFactory - interface Factory : AddToPortfolioManager.Factory { - override fun create( - scope: CoroutineScope, - token: TokenMarketParams, - analyticsParams: AddToPortfolioManager.AnalyticsParams?, - ): DefaultAddToPortfolioManager - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt deleted file mode 100644 index d90e3ca130..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state - -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM - -internal data class TokenActionsUM( - val token: TokenItemState, - val quickActions: PortfolioTokenUM.QuickActions, - val onLaterClick: () -> Unit, -) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt index a1cf255e9b..8e86530324 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -14,7 +14,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute @@ -66,7 +66,6 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( addToPortfolioManager = model.addToPortfolioManager, - callback = model.addToPortfolioCallback, ), ) is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt index 87ed9607b7..80c831f780 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -1,7 +1,7 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.analytics +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM internal class PortfolioAnalyticsEvent( event: String, @@ -21,60 +21,6 @@ internal class PortfolioAnalyticsEvent( }, ) - fun popupToChooseAccount() = PortfolioAnalyticsEvent( - event = "Choose Account Opened", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun popupToConfirm() = PortfolioAnalyticsEvent( - event = "Add Token Screen Opened", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addToNotMainAccount() = PortfolioAnalyticsEvent( - event = "Button - Add To Account", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addButtonClick() = PortfolioAnalyticsEvent( - event = "Button - Add Token", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent( - event = "Wallet Selected", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( - event = "Token Network Selected", - params = buildMap { - put("Count", blockchainNames.size.toString()) - put("Token", tokenSymbol) - put("blockchain", blockchainNames.joinToString(separator = ", ")) - if (source != null) put("Source", source) - }, - ) - - fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent( - event = "Token Added", - params = buildMap { - put("Token", tokenSymbol) - put("Blockchain", blockchainName) - if (source != null) put("Source", source) - }, - ) - fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) = PortfolioAnalyticsEvent( event = when (actionUM) { @@ -91,25 +37,5 @@ internal class PortfolioAnalyticsEvent( put("blockchain", blockchainName) }, ) - - fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent( - event = when (actionUM) { - TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy" - TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive" - TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange" - TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" - else -> "error" - }, - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun getTokenLater() = PortfolioAnalyticsEvent( - event = "Popup Get token - Button Later", - params = buildMap { - if (source != null) put("Source", source) - }, - ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt deleted file mode 100644 index 044a0ef229..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.loader - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.core.lce.Lce -import com.tangem.domain.models.TotalFiatBalance -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.tokens.model.TokenActionsState - -/** - * Portfolio data. Combined data from all flows that required to setup portfolio - * - * @property walletsWithCurrencies wallets with crypto currency statuses - * @property appCurrency app currency - * @property isBalanceHidden flag that indicates if balance should be hidden - * @property walletsWithBalance wallets with total balance - * -[REDACTED_AUTHOR] - */ -internal data class PortfolioData( - val walletsWithCurrencies: Map>, - val appCurrency: AppCurrency, - val isBalanceHidden: Boolean, - val walletsWithBalance: Map>, -) { - data class CryptoCurrencyData( - val userWallet: UserWallet, - val status: CryptoCurrencyStatus, - val actions: List, - ) -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt index 7faa59b34f..f4ba22eab4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt @@ -5,6 +5,9 @@ import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.TokenActionsHandler +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.models.AccountStatusList @@ -28,7 +31,6 @@ import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.models.YieldSupplyAvailability import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioHeader import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioListItem @@ -52,6 +54,7 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor( private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, private val getUserWalletUseCase: GetUserWalletUseCase, private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase, + private val designFeatureToggles: DesignFeatureToggles, isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, @Assisted private val scope: CoroutineScope, @Assisted private val token: TokenMarketParams, @@ -228,6 +231,7 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor( isBalanceHidden = isBalanceHidden, onTokenItemClick = { }, tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = designFeatureToggles.isRedesignEnabled, ) portfolio.portfolios.forEach { portfolioItem -> @@ -248,10 +252,12 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor( accountWithAdded.addedCurrency.forEach { currencyStatus -> val actions = allActions[currencyStatus.currency]?.states.orEmpty() - val value = PortfolioData.CryptoCurrencyData( + val value = CryptoCurrencyData( userWallet = userWallet, status = currencyStatus, actions = actions, + isAccountMode = isAccountMode, + account = accountWithAdded.accountStatus, ) val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id val isExpand = expanded.contains(expandedKey) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt index 0401beb1e7..b98f118a73 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -5,6 +5,8 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -12,14 +14,11 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* @@ -57,16 +56,18 @@ internal class MarketsPortfolioModel @Inject constructor( private val marketsPortfolioDelegate: MarketsPortfolioDelegate = createMarketsPortfolioDelegate() val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency) = bottomSheetNavigation.dismiss() - } init { marketsPortfolioDelegate.combineData() .onEach { state.value = it } .flowOn(dispatchers.default) .launchIn(modelScope) + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) } fun setTokenNetworks(networks: List) { @@ -82,9 +83,9 @@ internal class MarketsPortfolioModel @Inject constructor( private fun createAddToPortfolioManager(): AddToPortfolioManager { return addToPortfolioManagerFactory.create( scope = modelScope, - token = params.token, - analyticsParams = params.analyticsParams?.source?.let { AddToPortfolioManager.AnalyticsParams(it) }, - ) + settings = AddToPortfolioManager.Settings.DefaultMarket, + analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source), + ).also { addToPortfolioManager -> addToPortfolioManager.setTokenParams(params.token) } } private fun createMarketsPortfolioDelegate(): MarketsPortfolioDelegate { @@ -94,11 +95,14 @@ internal class MarketsPortfolioModel @Inject constructor( tokenActionsHandler = tokenActionsHandler, buttonState = addToPortfolioManager.state.map { state -> when (state) { - is AddToPortfolioManager.State.AvailableToAdd -> { - MyPortfolioUM.Tokens.AddButtonState.Available + is AddToPortfolioManager.State.Ready -> { + if (state.isAvailableToAdd) { + MyPortfolioUM.Tokens.AddButtonState.Available + } else { + MyPortfolioUM.Tokens.AddButtonState.Unavailable + } } - AddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading - AddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable + AddToPortfolioManager.State.Loading -> MyPortfolioUM.Tokens.AddButtonState.Loading } }, onAddClick = { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt index 2a078e38ed..30ca070cee 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -1,19 +1,16 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.model +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason -import com.tangem.domain.tokens.model.TokenActionsState -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.toImmutableList /** * Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM] @@ -25,10 +22,11 @@ internal class PortfolioTokenUMConverter( private val isBalanceHidden: Boolean, private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, private val tokenActionsHandler: TokenActionsHandler, -) : Converter { + private val isRedesignEnabled: Boolean, +) : Converter { fun convertV2( - value: PortfolioData.CryptoCurrencyData, + value: CryptoCurrencyData, isQuickActionsShown: Boolean, onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit, ): PortfolioTokenUM { @@ -41,11 +39,15 @@ internal class PortfolioTokenUMConverter( walletId = value.userWallet.walletId, isBalanceHidden = isBalanceHidden, isQuickActionsShown = isQuickActionsShown, - quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + quickActions = quickActions( + cryptoData = value, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = isRedesignEnabled, + ), ) } - override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM { + override fun convert(value: CryptoCurrencyData): PortfolioTokenUM { val tokenItemStateConverter = TokenItemStateConverter( appCurrency = appCurrency, titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) }, @@ -60,65 +62,11 @@ internal class PortfolioTokenUMConverter( walletId = value.userWallet.walletId, isBalanceHidden = isBalanceHidden, isQuickActionsShown = false, - quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + quickActions = quickActions( + cryptoData = value, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = isRedesignEnabled, + ), ) } - - companion object { - fun quickActions( - cryptoData: PortfolioData.CryptoCurrencyData, - tokenActionsHandler: TokenActionsHandler, - ): PortfolioTokenUM.QuickActions { - return PortfolioTokenUM.QuickActions( - actions = toQuickActions(cryptoData.actions), - onQuickActionClick = { quickActionUM -> - when (quickActionUM) { - QuickActionUM.Buy -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Buy, - cryptoCurrencyData = cryptoData, - ) - is QuickActionUM.Exchange -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Exchange, - cryptoCurrencyData = cryptoData, - ) - QuickActionUM.Receive -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Receive, - cryptoCurrencyData = cryptoData, - ) - QuickActionUM.Stake -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Stake, - cryptoCurrencyData = cryptoData, - ) - is QuickActionUM.YieldMode -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.YieldMode, - cryptoCurrencyData = cryptoData, - ) - } - }, - onQuickActionLongClick = { actionUM -> - if (actionUM == QuickActionUM.Receive) { - tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.CopyAddress, - cryptoCurrencyData = cryptoData, - ) - } - }, - ) - } - - fun toQuickActions(actions: List) = buildList { - actions.forEach { action -> - if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { - when (action) { - is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy - is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.shouldShowBadge) - is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive - is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake - is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) - else -> null - }?.let(::add) - } - } - }.toImmutableList() - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt index 2260f52c5d..8c447cad20 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.markets.action.QuickActionUM import com.tangem.core.ui.components.SpacerH4 import com.tangem.core.ui.components.icons.badge.drawBadge import com.tangem.core.ui.extensions.resolveReference @@ -36,7 +37,6 @@ import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -180,7 +180,7 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { ) .size(TangemTheme.dimens.size32) .semantics { - contentDescription = if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + contentDescription = if (state is QuickActionUM.V1.Exchange && state.shouldShowBadge) { "Badge shown" } else { "Badge hidden" @@ -188,7 +188,7 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { } .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + if (state is QuickActionUM.V1.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -229,9 +229,9 @@ private fun Preview() { ) { PortfolioQuickActions( actions = persistentListOf( - QuickActionUM.Buy, - QuickActionUM.Exchange(shouldShowBadge = true), - QuickActionUM.Receive, + QuickActionUM.V1.Buy, + QuickActionUM.V1.Exchange(shouldShowBadge = true), + QuickActionUM.V1.Receive, ), isVisible = isVisible, onActionClick = {}, @@ -250,9 +250,9 @@ private fun PreviewRtl() { Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) { PortfolioQuickActions( actions = persistentListOf( - QuickActionUM.Buy, - QuickActionUM.Exchange(shouldShowBadge = true), - QuickActionUM.Receive, + QuickActionUM.V1.Buy, + QuickActionUM.V1.Exchange(shouldShowBadge = true), + QuickActionUM.V1.Receive, ), isVisible = true, onActionClick = {}, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt index f84d983885..38675976ef 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -3,6 +3,8 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.pre import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.markets.action.QuickActionUM +import com.tangem.common.ui.markets.action.QuickActions import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState @@ -125,11 +127,11 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider, - val onQuickActionClick: (QuickActionUM) -> Unit, - val onQuickActionLongClick: (QuickActionUM) -> Unit, - ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt deleted file mode 100644 index c98c012df0..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state - -import androidx.annotation.DrawableRes -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.features.feed.impl.R - -@Immutable -internal sealed class QuickActionUM( - val title: TextReference, - val description: TextReference, - @DrawableRes val icon: Int, - val isLongClickAvailable: Boolean = false, -) { - data object Buy : QuickActionUM( - title = resourceReference(R.string.common_buy), - description = resourceReference(R.string.buy_token_description), - icon = R.drawable.ic_plus_24, - ) - - data class Exchange( - val shouldShowBadge: Boolean, - ) : QuickActionUM( - title = resourceReference(R.string.common_exchange), - description = resourceReference(R.string.exсhange_token_description), - icon = R.drawable.ic_exchange_vertical_24, - ) - - data object Receive : QuickActionUM( - title = resourceReference(R.string.common_receive), - description = resourceReference(R.string.receive_token_description), - icon = R.drawable.ic_arrow_down_24, - isLongClickAvailable = true, - ) - - data object Stake : QuickActionUM( - title = resourceReference(R.string.common_stake), - description = resourceReference(R.string.stake_token_description), - icon = R.drawable.ic_staking_24, - ) - - data class YieldMode( - private val apy: String, - ) : QuickActionUM( - title = resourceReference(R.string.common_yield_mode), - description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), - icon = R.drawable.ic_analytics_up_mini_24, - ) -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt index 22f52fccc7..b9d274b71b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt @@ -5,24 +5,13 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.slot.childSlot -import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockModel -import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockRoute import com.tangem.features.feed.components.market.details.portfolioblock.ui.PortfolioBlock -import com.tangem.features.feed.components.portfolio.PortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -32,26 +21,14 @@ import kotlinx.serialization.Serializable internal class PortfolioBlockComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: Params, + @Assisted private val parentRouter: PortfolioBlockParentClickIntents?, ) : ComposableContentComponent, AppComponentContext by context { - private val portfolioComponentFactory: PortfolioComponent.Factory = object : PortfolioComponent.Factory { - override fun create(context: AppComponentContext, params: PortfolioComponent.Params): PortfolioComponent { - TODO("STUB. Will be implemented") - } - } - - @Serializable - data class Params( - val token: TokenMarketParams, - ) - - private val model: PortfolioBlockModel = getOrCreateModel(params) - - private val bottomSheetSlot = childSlot( - source = model.bottomSheetNavigation, - serializer = PortfolioBlockRoute.serializer(), - handleBackButton = true, - childFactory = ::createBottomSheetChild, + private val model: PortfolioBlockModel = getOrCreateModel( + PortfolioBlockModelParams( + token = params.token, + parentRouter = parentRouter, + ), ) fun setTokenNetworks(networks: List) { @@ -65,51 +42,23 @@ internal class PortfolioBlockComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - val bottomSheet by bottomSheetSlot.subscribeAsState() - PortfolioBlock(modifier = modifier, state = state) - bottomSheet.child?.instance?.BottomSheet() } - @Suppress("UnusedParameter") - private fun createBottomSheetChild( - config: PortfolioBlockRoute, - componentContext: ComponentContext, - ): ComposableBottomSheetComponent { - val currencyId = model.cryptoCurrencyIdState.value ?: return ComposableBottomSheetComponent.EMPTY - val portfolioComponent = portfolioComponentFactory.create( - context = childByContext(componentContext), - params = PortfolioComponent.Params(id = currencyId), - ) - return PortfolioBottomSheetWrapper( - portfolioComponent = portfolioComponent, - onDismiss = { model.bottomSheetNavigation.dismiss() }, - ) - } - - private class PortfolioBottomSheetWrapper( - private val portfolioComponent: PortfolioComponent, - private val onDismiss: () -> Unit, - ) : ComposableBottomSheetComponent { - - override fun dismiss() = onDismiss() - - @Composable - override fun BottomSheet() { - TangemBottomSheet( - config = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = ::dismiss, - content = TangemBottomSheetConfigContent.Empty, - ), - ) { - portfolioComponent.Content(modifier = Modifier) - } - } - } + @Serializable + data class Params(val token: TokenMarketParams) @AssistedFactory interface Factory { - fun create(context: AppComponentContext, params: Params): PortfolioBlockComponent + fun create( + context: AppComponentContext, + params: Params, + parentRouter: PortfolioBlockParentClickIntents?, + ): PortfolioBlockComponent } + + data class PortfolioBlockModelParams( + val token: TokenMarketParams, + val parentRouter: PortfolioBlockParentClickIntents?, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockParentClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockParentClickIntents.kt new file mode 100644 index 0000000000..8be21c2648 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockParentClickIntents.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.components.market.details.portfolioblock + +import com.tangem.domain.models.currency.CryptoCurrency + +internal interface PortfolioBlockParentClickIntents { + fun openAddToPortfolioDirect() + fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt index 48901a9e3c..873efb8c79 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt @@ -2,8 +2,6 @@ package com.tangem.features.feed.components.market.details.portfolioblock.model import androidx.compose.runtime.Stable import androidx.compose.ui.text.SpanStyle -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.common.getTotalFiatAmount import com.tangem.core.decompose.di.ModelScoped @@ -26,6 +24,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent +import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents import com.tangem.features.feed.components.market.details.portfolioblock.ui.state.PortfolioBlockUM import com.tangem.features.feed.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -50,13 +49,12 @@ internal class PortfolioBlockModel @Inject constructor( val state: StateFlow field = MutableStateFlow(PortfolioBlockUM.Loading) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val cryptoCurrencyIdState: StateFlow field = MutableStateFlow(null) - private val params = paramsContainer.require() + private val params = paramsContainer.require() private val currencyRawId: CryptoCurrency.RawID = params.token.id + private val parentRouter: PortfolioBlockParentClickIntents? = params.parentRouter private val tokenIcon: CurrencyIconState = CurrencyIconState.CoinIcon( url = params.token.imageUrl, @@ -114,15 +112,12 @@ internal class PortfolioBlockModel @Inject constructor( } }.distinctUntilChanged() - val settingsFlow = combine( + return combine( + portfolioDataFlow, getSelectedAppCurrencyUseCase.invokeOrDefault(), getBalanceHidingSettingsUseCase.isBalanceHidden(), - ) { appCurrency, isBalanceHidden -> - SettingsBox(appCurrency, isBalanceHidden) - }.distinctUntilChanged() - - return combine(portfolioDataFlow, settingsFlow) { portfolios, settings -> - buildState(portfolios, settings) + ) { portfolios, appCurrency, isBalanceHidden -> + buildState(portfolios, appCurrency, isBalanceHidden) }.distinctUntilChanged() } @@ -135,7 +130,11 @@ internal class PortfolioBlockModel @Inject constructor( } } - private fun buildState(portfolios: List, settings: SettingsBox): PortfolioBlockUM { + private fun buildState( + portfolios: List, + appCurrency: AppCurrency, + isBalanceHidden: Boolean, + ): PortfolioBlockUM { val allCurrencies = portfolios.flatMap { it.currencies } val hasMultiCurrencyWallet = portfolios.any { it.userWallet.isMultiCurrency } @@ -143,36 +142,36 @@ internal class PortfolioBlockModel @Inject constructor( return if (hasMultiCurrencyWallet) { PortfolioBlockUM.AddToken( tokenIcon = tokenIcon, - onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) }, + onAddClick = { parentRouter?.openAddToPortfolioDirect() }, ) } else { PortfolioBlockUM.Hidden } } - cryptoCurrencyIdState.update { - it ?: allCurrencies.first().currency.id - } + cryptoCurrencyIdState.update { it ?: allCurrencies.first().currency.id } val totalFiat = allCurrencies.mapNotNull { it.getTotalFiatAmount() } .fold(BigDecimal.ZERO, BigDecimal::add) val formattedBalance = totalFiat.formatStyled { fiat( - fiatCurrencyCode = settings.appCurrency.code, - fiatCurrencySymbol = settings.appCurrency.symbol, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, ) } + val firstCurrency = allCurrencies.first().currency return PortfolioBlockUM.Content( totalBalance = formattedBalance, tokensInPortfolioCount = allCurrencies.size, tokenIcon = tokenIcon, - tokenName = allCurrencies.first().currency.name, - tokenSymbol = allCurrencies.first().currency.symbol, - isBalanceHidden = settings.isBalanceHidden, - onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) }, + tokenName = firstCurrency.name, + tokenSymbol = firstCurrency.symbol, + isBalanceHidden = isBalanceHidden, + onRowClick = { parentRouter?.openAddToPortfolioViaUserPortfolio(currencyRawId) }, + onAddFundsClick = {}, ) } } @@ -180,9 +179,4 @@ internal class PortfolioBlockModel @Inject constructor( private data class WalletPortfolio( val userWallet: UserWallet, val currencies: List, -) - -private data class SettingsBox( - val appCurrency: AppCurrency, - val isBalanceHidden: Boolean, ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt deleted file mode 100644 index 4c1f19107b..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolioblock.model - -import com.tangem.core.decompose.navigation.Route -import kotlinx.serialization.Serializable - -@Serializable -internal data object PortfolioBlockRoute : Route \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt index 06cc01d383..7ca136dcf8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt @@ -10,7 +10,6 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetDefaults @@ -26,7 +25,6 @@ import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.BottomFade import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.currency.icon.CurrencyIcon @@ -60,6 +58,7 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi } AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), visible = isVisible, enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)), exit = fadeOut(animationSpec = tween(durationMillis = 300)), @@ -95,38 +94,10 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi @Composable private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) { - FloatingCard( - modifier = modifier, - onClick = state.onClick, - ) { - TangemRowContainer( - contentPadding = PaddingValues(0.dp), - ) { - CurrencyIcon( - modifier = Modifier - .padding(end = TangemTheme.dimens2.x3) - .layoutId(TangemRowLayoutId.HEAD), - state = state.tokenIcon, - ) - + FloatingCard(modifier = modifier) { + TangemRowContainer(modifier = Modifier.clickableSingle(onClick = state.onRowClick)) { Text( modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), - text = state.tokenName, - style = TangemTheme.typography2.bodyMedium16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - - Text( - modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), - text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), - style = TangemTheme.typography2.captionMedium12, - color = TangemTheme.colors2.text.neutral.secondary, - maxLines = 1, - ) - - Text( - modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -135,26 +106,43 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M ) Text( - modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), - text = state.tokenSymbol, + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.secondary, maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + TangemButton( + modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + buttonUM = TangemButtonUM( + text = resourceReference(R.string.common_add_funds), + type = TangemButtonType.Secondary, + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_arrow_down_20, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + iconPosition = TangemButtonIconPosition.Start, + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X9, + onClick = state.onAddFundsClick, + ), ) TangemButton( modifier = Modifier - .padding(start = TangemTheme.dimens2.x3) - .layoutId(TangemRowLayoutId.TAIL), + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x3), buttonUM = TangemButtonUM( type = TangemButtonType.Secondary, tangemIconUM = TangemIconUM.Icon( - iconRes = R.drawable.ic_chevron_24, + iconRes = R.drawable.ic_arrow_expand_24, tintReference = { TangemTheme.colors2.graphic.neutral.primary }, ), shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onClick, + onClick = state.onRowClick, ), ) } @@ -163,11 +151,13 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M @Composable private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) { - FloatingCard( - modifier = modifier, - onClick = state.onClick, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { + FloatingCard(modifier = modifier) { + Row( + modifier = Modifier + .padding(TangemTheme.dimens2.x3) + .clickableSingle(onClick = state.onAddClick), + verticalAlignment = Alignment.CenterVertically, + ) { CurrencyIcon(state.tokenIcon) SpacerW(TangemTheme.dimens2.x3) @@ -191,7 +181,7 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = text = resourceReference(R.string.common_add), shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onClick, + onClick = state.onAddClick, ), ) } @@ -200,7 +190,7 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {}, content: @Composable () -> Unit) { +private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { Box( modifier = modifier .navigationBarsPadding() @@ -215,9 +205,7 @@ private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {} .background( color = TangemTheme.colors2.surface.level3, shape = RoundedCornerShape(size = TangemTheme.dimens2.x5), - ) - .clickable(onClick = onClick) - .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3), + ), ) { content() } @@ -236,7 +224,8 @@ private fun ContentPreview() { tokenName = "Bitcoin", tokenSymbol = "BTC", isBalanceHidden = false, - onClick = {}, + onRowClick = {}, + onAddFundsClick = {}, ), ) } @@ -250,7 +239,7 @@ private fun AddTokenPreview() { PortfolioBlock( state = PortfolioBlockUM.AddToken( tokenIcon = previewCoinIcon, - onClick = {}, + onAddClick = {}, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt index f61b90aa05..136a952189 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt @@ -12,7 +12,7 @@ internal sealed class PortfolioBlockUM { data class AddToken( val tokenIcon: CurrencyIconState, - val onClick: () -> Unit, + val onAddClick: () -> Unit, ) : PortfolioBlockUM() data class Content( @@ -22,6 +22,7 @@ internal sealed class PortfolioBlockUM { val tokenName: String, val tokenSymbol: String, val isBalanceHidden: Boolean, - val onClick: () -> Unit, + val onRowClick: () -> Unit, + val onAddFundsClick: () -> Unit, ) : PortfolioBlockUM() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt index 0fd788caa3..79cbf99fa3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt @@ -1,6 +1,5 @@ package com.tangem.features.feed.components.market.list -import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -30,6 +29,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.model.market.list.MarketsListModel +import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.market.list.MarketsList @@ -65,9 +65,8 @@ internal class DefaultMarketsTokenListComponent( .hazeEffectTangem { progressive = HazeProgressive.verticalGradient( startIntensity = .55f, - endIntensity = 0f, + endIntensity = .2f, preferPerformance = true, - easing = EaseOut, ) }, startContent = { @@ -129,12 +128,13 @@ internal class DefaultMarketsTokenListComponent( @Serializable data class Params( val preselectedSortType: SortByTypeUM, + val preselectedInterval: MarketsListUM.TrendInterval, val shouldAlwaysShowSearchBar: Boolean, ) data class ClickIntents( val onBackClicked: () -> Unit, - val onSearchClicked: () -> Unit, + val onSearchClicked: (source: String) -> Unit, val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index 58a30e4a3a..1325a29be6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -1,6 +1,5 @@ package com.tangem.features.feed.components.news.list -import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -52,10 +51,10 @@ internal class DefaultNewsListComponent( modifier = Modifier.hazeEffectTangem { progressive = HazeProgressive.verticalGradient( startIntensity = .55f, - endIntensity = 0f, + endIntensity = .2f, preferPerformance = true, - easing = EaseOut, ) + backgroundColor = background }, title = resourceReference(R.string.common_news), type = TangemTopBarType.BottomSheet, @@ -113,5 +112,6 @@ internal class DefaultNewsListComponent( paginationConfig: NewsListConfig?, ) -> Unit, val onBackClick: () -> Unit, + val preselectedCategoryId: Int? = null, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt index b8a8ac1119..0ad95b2ae6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt @@ -6,15 +6,22 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.ds.field.search.TangemFieldShape import com.tangem.core.ui.ds.field.search.TangemSearchField import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.model.search.SearchModel import com.tangem.features.feed.ui.search.SearchContent import com.tangem.features.feed.ui.search.state.SearchCallbacks @@ -27,6 +34,13 @@ internal class DefaultSearchComponent( private val model = getOrCreateModel(params = params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + @Composable override fun Title(bottomSheetState: State) { val state by model.state.collectAsStateWithLifecycle() @@ -67,6 +81,7 @@ internal class DefaultSearchComponent( contentPadding: PaddingValues, modifier: Modifier, ) { + val bottomSheet by bottomSheetSlot.subscribeAsState() val state by model.state.collectAsStateWithLifecycle() val searchCallbacks = remember { SearchCallbacks( @@ -74,6 +89,7 @@ internal class DefaultSearchComponent( onClearHintsClick = model::clearSearchHistory, onTextHintClick = model::onTextHintClick, onResultMarketTokenClick = model::onResultMarketTokenClick, + onHistoryTokenClick = model::onHistoryTokenClick, ) } SearchContent( @@ -82,9 +98,28 @@ internal class DefaultSearchComponent( searchCallbacks = searchCallbacks, contentPadding = contentPadding, ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: SearchBottomSheetRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + is SearchBottomSheetRoute.TokenSelector -> SearchTokenSelectorComponent( + context = childByContext(componentContext), + params = SearchTokenSelectorComponent.Params( + entries = config.entries, + appCurrency = config.appCurrency, + isBalanceHidden = config.isBalanceHidden, + onTokenSelected = config.onTokenSelected, + onDismiss = config.onDismiss, + ), + ) } data class Params( val onBackClick: () -> Unit, + val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), + val sourceParams: String, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt new file mode 100644 index 0000000000..b595fbe70c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt @@ -0,0 +1,15 @@ +package com.tangem.features.feed.components.search + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.portfolio.UserAssetEntry + +internal sealed interface SearchBottomSheetRoute { + + data class TokenSelector( + val entries: List, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val onTokenSelected: (UserAssetEntry) -> Unit, + val onDismiss: () -> Unit, + ) : SearchBottomSheetRoute +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt new file mode 100644 index 0000000000..6fe668dc82 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt @@ -0,0 +1,46 @@ +package com.tangem.features.feed.components.search + +import androidx.compose.runtime.Composable +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.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.common.ui.markets.tokenselector.TokenSelectorBottomSheet +import com.tangem.features.feed.model.search.SearchTokenSelectorModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +internal class SearchTokenSelectorComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableBottomSheetComponent { + + private val model = getOrCreateModel(params = params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state = model.state.collectAsStateWithLifecycle() + TokenSelectorBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = state.value, + ), + ) + } + + data class Params( + val entries: List, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val onTokenSelected: (UserAssetEntry) -> Unit, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt index 2e81775f75..b2d72c2863 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt @@ -2,20 +2,31 @@ package com.tangem.features.feed.deeplink import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.INTERVAL_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.ORDER_KEY +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler +import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject internal class DefaultMarketsDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, appRouter: AppRouter, ) : MarketsDeepLinkHandler { init { - appRouter.push(AppRoute.Markets) + appRouter.push( + AppRoute.Markets( + preselectedOrder = PreselectedMarketsOrder.parse(queryParams[ORDER_KEY]), + preselectedInterval = PreselectedMarketsInterval.parse(queryParams[INTERVAL_KEY]), + ), + ) } @AssistedFactory interface Factory : MarketsDeepLinkHandler.Factory { - override fun create(): DefaultMarketsDeepLinkHandler + override fun create(params: Map): DefaultMarketsDeepLinkHandler } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt index 89c501508c..89cc9b9877 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt @@ -2,7 +2,9 @@ package com.tangem.features.feed.deeplink import arrow.core.getOrElse import com.tangem.common.routing.AppRoute +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.SECTION_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -11,12 +13,12 @@ import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject constructor( @Assisted private val scope: CoroutineScope, @@ -32,8 +34,15 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc private fun handleDeepLink() { val tokenId = queryParams[TOKEN_ID_KEY] + val section = PreselectedTokenDetailsSection.parse(queryParams[SECTION_KEY]) - val rawTokenId = CryptoCurrency.RawID(tokenId.orEmpty()) + if (tokenId.isNullOrEmpty()) { + TangemLogger.e("Markets token details deeplink does not contain token_id") + appRouter.push(AppRoute.Markets()) + return + } + + val rawTokenId = CryptoCurrency.RawID(tokenId) scope.launch { val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { @@ -65,6 +74,7 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc appCurrency = appCurrency, shouldShowPortfolio = true, analyticsParams = null, + preselectedSection = section, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenExchangesDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenExchangesDeepLinkHandler.kt new file mode 100644 index 0000000000..4fbd77c4ba --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenExchangesDeepLinkHandler.kt @@ -0,0 +1,88 @@ +package com.tangem.features.feed.deeplink + +import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetTokenMarketInfoUseCase +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class DefaultMarketsTokenExchangesDeepLinkHandler @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val queryParams: Map, + private val appRouter: AppRouter, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, +) : MarketsTokenExchangesDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + val tokenId = queryParams[TOKEN_ID_KEY] + + if (tokenId.isNullOrEmpty()) { + TangemLogger.e("Markets token exchanges deeplink does not contain token_id") + appRouter.push(AppRoute.Markets()) + return + } + + val rawTokenId = CryptoCurrency.RawID(tokenId) + + scope.launch { + val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { + AppCurrency.Default + } + val tokenInfo = getTokenMarketInfoUseCase( + appCurrency = appCurrency, + tokenId = rawTokenId, + tokenSymbol = "", + ).getOrElse { + TangemLogger.e("Failed to get market token info for exchanges deeplink") + appRouter.push(AppRoute.Markets()) + return@launch + } + + appRouter.push( + AppRoute.MarketsTokenDetails( + token = TokenMarketParams( + id = rawTokenId, + name = tokenInfo.name, + symbol = tokenInfo.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = tokenInfo.quotes.currentPrice, + h24Percent = tokenInfo.quotes.h24ChangePercent, + weekPercent = tokenInfo.quotes.weekChangePercent, + monthPercent = tokenInfo.quotes.monthChangePercent, + ), + imageUrl = getTokenIconUrlFromDefaultHost(rawTokenId), + ), + appCurrency = appCurrency, + shouldShowPortfolio = true, + shouldOpenExchanges = true, + exchangesCount = tokenInfo.exchangesAmount, + ), + ) + } + } + + @AssistedFactory + interface Factory : MarketsTokenExchangesDeepLinkHandler.Factory { + override fun create( + coroutineScope: CoroutineScope, + queryParams: Map, + ): DefaultMarketsTokenExchangesDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandler.kt new file mode 100644 index 0000000000..66aab9b849 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandler.kt @@ -0,0 +1,35 @@ +package com.tangem.features.feed.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.CATEGORY_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NEWS_ID_KEY +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNewsDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, + private val appRouter: AppRouter, +) : NewsDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + val newsId = queryParams[NEWS_ID_KEY]?.toIntOrNull() + if (newsId != null) { + appRouter.push(AppRoute.NewsDetails(newsId = newsId)) + return + } + + appRouter.push(AppRoute.News(categoryId = queryParams[CATEGORY_ID_KEY]?.toIntOrNull())) + } + + @AssistedFactory + interface Factory : NewsDeepLinkHandler.Factory { + override fun create(queryParams: Map): DefaultNewsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt index d00b30cc79..05f42eb535 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt @@ -1,6 +1,8 @@ package com.tangem.features.feed.deeplink.di +import com.tangem.features.feed.deeplink.DefaultNewsDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import dagger.Binds import dagger.Module @@ -17,4 +19,8 @@ internal interface FeedDeepLinkModule { fun bindNewsDetailsDeepLinkHandlerFactory( impl: DefaultNewsDetailsDeepLinkHandler.Factory, ): NewsDetailsDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindNewsDeepLinkHandlerFactory(impl: DefaultNewsDeepLinkHandler.Factory): NewsDeepLinkHandler.Factory } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt index def0bf84fb..1e1e90cc24 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt @@ -2,8 +2,10 @@ package com.tangem.features.feed.deeplink.di import com.tangem.features.feed.deeplink.DefaultMarketsDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultMarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.deeplink.DefaultMarketsTokenExchangesDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -23,4 +25,10 @@ internal interface MarketsDeepLinkModule { fun bindMarketsTokenDetailDeepLinkHandlerFactory( impl: DefaultMarketsTokenDetailDeepLinkHandler.Factory, ): MarketsTokenDetailDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindMarketsTokenExchangesDeepLinkHandlerFactory( + impl: DefaultMarketsTokenExchangesDeepLinkHandler.Factory, + ): MarketsTokenExchangesDeepLinkHandler.Factory } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt deleted file mode 100644 index 7e9bee8655..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.feed.di - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle -import com.tangem.features.feed.featuretoggle.DefaultFeedFeatureToggle -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object FeedFeatureToggleModule { - - @Provides - @Singleton - fun provideFeedFeatureToggle(featureTogglesManager: FeatureTogglesManager): FeedFeatureToggle { - return DefaultFeedFeatureToggle( - featureTogglesManager = featureTogglesManager, - ) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index 72864031f8..69f12c89b5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -12,6 +12,7 @@ import com.tangem.features.feed.model.market.list.MarketsListModel import com.tangem.features.feed.model.news.details.NewsDetailsModel import com.tangem.features.feed.model.news.list.NewsListModel import com.tangem.features.feed.model.search.SearchModel +import com.tangem.features.feed.model.search.SearchTokenSelectorModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -71,4 +72,9 @@ internal interface ModelModule { @IntoMap @ClassKey(SearchModel::class) fun provideSearchModel(model: SearchModel): Model + + @Binds + @IntoMap + @ClassKey(SearchTokenSelectorModel::class) + fun provideSearchTokenSelectorModel(model: SearchTokenSelectorModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt deleted file mode 100644 index 025dd02c28..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.features.feed.featuretoggle - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle - -internal class DefaultFeedFeatureToggle( - private val featureTogglesManager: FeatureTogglesManager, -) : FeedFeatureToggle { - - override val isEarnBlockEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.EARN_BLOCK_ENABLED) -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt index 66f262955c..dfdd8f7610 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.feed.model.converter -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.format diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt index b565dd823c..1fde08923e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt @@ -7,7 +7,7 @@ import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM -import com.tangem.features.feed.ui.utils.mapFormattedDate +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index ac71423894..d795a1289d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -8,6 +8,7 @@ import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -16,17 +17,18 @@ import com.tangem.domain.earn.model.EarnFilter import com.tangem.domain.earn.model.EarnFilterNetwork import com.tangem.domain.earn.model.EarnFilterType import com.tangem.domain.earn.usecase.* +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnNetworks import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent +import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkConverter import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConverter import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeConverter @@ -47,7 +49,7 @@ import javax.inject.Inject @Stable @ModelScoped -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class EarnModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, @@ -61,6 +63,7 @@ internal class EarnModel @Inject constructor( private val appRouter: AppRouter, private val stateController: EarnStateController, private val analyticsEventHandler: AnalyticsEventHandler, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, ) : Model() { private val params = paramsContainer.require() @@ -81,21 +84,24 @@ internal class EarnModel @Inject constructor( dispatchers = dispatchers, ) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - - val addToPortfolioCallback = object : AddToPortfolioPreselectedDataComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) { - bottomSheetNavigation.dismiss() - appRouter.push( - AppRoute.CurrencyDetails( - userWalletId = walletId, - currency = addedToken, - ), - ) - } + val addBestOpportunitiesPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.BEST_OPPORTUNITIES_SOURCE.value), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) } + val addMostlyUsedPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.MOSTLY_USED_SOURCE.value), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) + } + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val state: StateFlow get() = stateController.uiState @@ -106,6 +112,39 @@ internal class EarnModel @Inject constructor( subscribeOnNetworks() subscribeOnBatchFlow() subscribeToMostlyUsed() + + addBestOpportunitiesPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addBestOpportunitiesPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + addBestOpportunitiesPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + + addMostlyUsedPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addMostlyUsedPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + addMostlyUsedPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + } + + private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) } private fun subscribeOnBatchFlow() { @@ -259,30 +298,36 @@ internal class EarnModel @Inject constructor( } } - private fun onEarnTokenClick(earnTokenWithCurrency: EarnTokenWithCurrency, source: String) { + private fun onEarnTokenClick(earnTokenWithCurrency: EarnTokenWithCurrency, source: EarnSource) { analyticsEventHandler.send( EarnAnalyticsEvent.OpportunitySelected( tokenSymbol = earnTokenWithCurrency.earnToken.tokenSymbol, blockchain = earnTokenWithCurrency.cryptoCurrency.network.name, - source = source, + source = source.value, ), ) - bottomSheetNavigation.activate( - FeedBottomSheetRoute.AddToPortfolio( - tokenToAdd = AddToPortfolioPreselectedDataComponent.TokenToAdd( - network = TokenMarketInfo.Network( - networkId = earnTokenWithCurrency.earnToken.networkId, - isExchangeable = false, - contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, - decimalCount = earnTokenWithCurrency.earnToken.decimalCount, - ), - id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), - name = earnTokenWithCurrency.earnToken.tokenName, - symbol = earnTokenWithCurrency.earnToken.tokenSymbol, - ), - source = source, - ), + val token = RawMarketToken( + id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), + name = earnTokenWithCurrency.earnToken.tokenName, + symbol = earnTokenWithCurrency.earnToken.tokenSymbol, ) + val network = TokenMarketInfo.Network( + networkId = earnTokenWithCurrency.earnToken.networkId, + isExchangeable = false, + contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, + decimalCount = earnTokenWithCurrency.earnToken.decimalCount, + ) + when (source) { + EarnSource.BEST_OPPORTUNITIES_SOURCE -> addBestOpportunitiesPortfolioManager.apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) + } + EarnSource.MOSTLY_USED_SOURCE -> addMostlyUsedPortfolioManager.apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) + } + } + bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(source.value)) } private fun onTypeFilterOptionSelected(type: EarnFilterType) { @@ -325,7 +370,7 @@ internal class EarnModel @Inject constructor( onNetworkFilterClick = ::onNetworkFilterClick, onTypeFilterClick = ::onTypeFilterClick, onScroll = ::onMostlyUsedScrolled, - onSearchBarClicked = params.onSearchClicked, + onSearchBarClicked = { params.onSearchClicked(AnalyticsParam.ScreensSources.Earn.value) }, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt index 3ff24b4467..2925791d66 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt @@ -50,30 +50,6 @@ internal sealed class EarnAnalyticsEvent( ), ) - data class AddTokenScreenOpened( - private val tokenSymbol: String, - private val blockchain: String, - private val source: String, - ) : EarnAnalyticsEvent( - event = "Add Token Screen Opened", - params = mapOf( - AnalyticsParam.TOKEN_PARAM to tokenSymbol, - AnalyticsParam.BLOCKCHAIN to blockchain, - AnalyticsParam.SOURCE to source, - ), - ) - - data class TokenAdded( - private val tokenSymbol: String, - private val blockchain: String, - ) : EarnAnalyticsEvent( - event = "Token Added", - params = mapOf( - AnalyticsParam.TOKEN_PARAM to tokenSymbol, - AnalyticsParam.BLOCKCHAIN to blockchain, - ), - ) - data class BestOpportunitiesLoadError( private val code: Int?, private val message: String, @@ -86,9 +62,10 @@ internal sealed class EarnAnalyticsEvent( ) } -internal const val BEST_OPPORTUNITIES_SOURCE = "Best Opportunity" -internal const val MOSTLY_USED_SOURCE = "Mostly Used" - +internal enum class EarnSource(val value: String) { + BEST_OPPORTUNITIES_SOURCE("Best Opportunity"), + MOSTLY_USED_SOURCE("Mostly Used"), +} internal enum class FilterNetworkAnalytic(val value: String) { ALL_NETWORKS("All Networks"), MY_NETWORKS("My Networks"), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt index 62f5348e16..347a4269b7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt @@ -2,7 +2,7 @@ package com.tangem.features.feed.model.earn.filters.state import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.domain.earn.model.EarnFilterNetwork import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM import com.tangem.utils.converter.Converter @@ -22,7 +22,7 @@ internal class EarnFilterNetworkConverter : Converter Unit, + private val onItemClick: (EarnTokenWithCurrency, source: EarnSource) -> Unit, private val onRetryClick: () -> Unit, ) : EarnUMTransformer { private val converter = EarnTokenWithCurrencyToListItemUMConverter( - onItemClick = { token -> onItemClick(token, MOSTLY_USED_SOURCE) }, + onItemClick = { token -> onItemClick(token, EarnSource.MOSTLY_USED_SOURCE) }, ) override fun transform(prevState: EarnUM): EarnUM { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt index c241d58af4..e762de7360 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt @@ -5,7 +5,7 @@ import com.tangem.domain.earn.model.EarnTokensListConfig import com.tangem.domain.earn.usecase.GetEarnTokensBatchFlowUseCase import com.tangem.domain.models.earn.EarnTokenWithCurrency import com.tangem.features.feed.model.converter.EarnTokenWithCurrencyToListItemUMConverter -import com.tangem.features.feed.model.earn.analytics.BEST_OPPORTUNITIES_SOURCE +import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.earn.state.EarnListItemUM import com.tangem.pagination.BatchAction import com.tangem.pagination.PaginationStatus @@ -21,13 +21,13 @@ import kotlinx.coroutines.launch internal class EarnListBatchFlowManager( getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase, private val configProvider: Provider, - private val onItemClick: (EarnTokenWithCurrency, source: String) -> Unit, + private val onItemClick: (EarnTokenWithCurrency, source: EarnSource) -> Unit, private val modelScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { private val actionsFlow = MutableSharedFlow>() private val converter = EarnTokenWithCurrencyToListItemUMConverter( - onItemClick = { onItemClick(it, BEST_OPPORTUNITIES_SOURCE) }, + onItemClick = { onItemClick(it, EarnSource.BEST_OPPORTUNITIES_SOURCE) }, ) private val batchFlow = getEarnTokensBatchFlowUseCase( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 33fc8bb216..7a7c71ccc5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -20,19 +20,14 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.earn.usecase.FetchTopEarnTokensUseCase import com.tangem.domain.earn.usecase.GetTopEarnTokensUseCase -import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketListConfig -import com.tangem.domain.markets.toSerializableParam +import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent @@ -63,11 +58,11 @@ internal class FeedComponentModel @Inject constructor( private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val stateController: FeedStateController, - private val feedFeatureToggle: FeedFeatureToggle, private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase, private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase, private val appRouter: AppRouter, private val designFeatureToggles: DesignFeatureToggles, + addToPortfolioManagerFactory: AddToPortfolioManager.Factory, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, @@ -92,21 +87,16 @@ internal class FeedComponentModel @Inject constructor( dispatchers = dispatchers, ) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - - val addToPortfolioCallback = object : AddToPortfolioPreselectedDataComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) { - bottomSheetNavigation.dismiss() - appRouter.push( - AppRoute.CurrencyDetails( - userWalletId = walletId, - currency = addedToken, - ), - ) - } + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = AnalyticsParam.ScreensSources.Markets.value), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val state: StateFlow get() = stateController.uiState @@ -120,6 +110,27 @@ internal class FeedComponentModel @Inject constructor( fetchCharts() subscribeOnCurrencyUpdate() subscribeOnDataState() + + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + addToPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + } + + private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) } private fun subscribeOnDataState() { @@ -147,7 +158,6 @@ internal class FeedComponentModel @Inject constructor( } }, analyticsEventHandler = analyticsEventHandler, - feedFeatureToggle = feedFeatureToggle, ) val currentState = stateController.value @@ -171,7 +181,7 @@ internal class FeedComponentModel @Inject constructor( analyticsEventHandler = analyticsEventHandler, ), UpdateEarnStateTransformer( - isEarnEnabled = feedFeatureToggle.isEarnBlockEnabled, + isEarnEnabled = true, onItemClick = ::handleEarnTokenClick, onRetryClick = ::fetchEarnData, earnResult = earnResult, @@ -206,7 +216,6 @@ internal class FeedComponentModel @Inject constructor( } private fun fetchEarnData() { - if (!feedFeatureToggle.isEarnBlockEnabled) return modelScope.launch(dispatchers.default) { stateController.update(UpdateEarnLoadingStateTransformer()) fetchTopEarnTokensUseCase() @@ -244,7 +253,7 @@ internal class FeedComponentModel @Inject constructor( onBarClick = { analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) if (designFeatureToggles.isRedesignEnabled) { - params.feedClickIntents.openSearch() + params.feedClickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value) } else { params.feedClickIntents.onMarketOpenClick(null) } @@ -280,11 +289,7 @@ internal class FeedComponentModel @Inject constructor( currentSortByType = SortByTypeUM.TopGainers, ), globalState = GlobalFeedState.Loading, - earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) { - EarnListUM.Loading - } else { - EarnListUM.Empty - }, + earnListUM = EarnListUM.Loading, ) } @@ -415,22 +420,23 @@ internal class FeedComponentModel @Inject constructor( source = AnalyticsParam.ScreensSources.Markets.value, ), ) - bottomSheetNavigation.activate( - FeedBottomSheetRoute.AddToPortfolio( - tokenToAdd = AddToPortfolioPreselectedDataComponent.TokenToAdd( - network = TokenMarketInfo.Network( - networkId = earnTokenWithCurrency.earnToken.networkId, - isExchangeable = false, - contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, - decimalCount = earnTokenWithCurrency.earnToken.decimalCount, - ), - id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), - name = earnTokenWithCurrency.earnToken.tokenName, - symbol = earnTokenWithCurrency.earnToken.tokenSymbol, - ), - source = AnalyticsParam.ScreensSources.Markets.value, - ), + val token = RawMarketToken( + id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), + name = earnTokenWithCurrency.earnToken.tokenName, + symbol = earnTokenWithCurrency.earnToken.tokenSymbol, ) + val network = TokenMarketInfo.Network( + networkId = earnTokenWithCurrency.earnToken.networkId, + isExchangeable = false, + contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, + decimalCount = earnTokenWithCurrency.earnToken.decimalCount, + ) + val route = FeedBottomSheetRoute.AddToPortfolio(AnalyticsParam.ScreensSources.Markets.value) + addToPortfolioManager.apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) + } + bottomSheetNavigation.activate(route) } private fun handleEarnPageOpenClicked() { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 96ff7cc2f1..7c81362bf3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -30,5 +30,5 @@ internal interface FeedModelClickIntents { fun onOpenEarnPage() - fun openSearch() + fun openSearch(source: String) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt index 7100cff366..73fe72d6b2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt @@ -2,7 +2,6 @@ package com.tangem.features.feed.model.feed.state import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.earn.state.EarnListUM @@ -16,9 +15,7 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject @ModelScoped -internal class FeedStateController @Inject constructor( - private val feedFeatureToggle: FeedFeatureToggle, -) { +internal class FeedStateController @Inject constructor() { private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) @@ -67,11 +64,7 @@ internal class FeedStateController @Inject constructor( currentSortByType = SortByTypeUM.Trending, ), globalState = GlobalFeedState.Loading, - earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) { - EarnListUM.Loading - } else { - EarnListUM.Empty - }, + earnListUM = EarnListUM.Loading, ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt index 56d9d140a4..ef14cf08bc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt @@ -3,7 +3,6 @@ package com.tangem.features.feed.model.feed.state.transformers import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.models.earn.EarnTopToken import com.tangem.domain.models.news.TrendingNews -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.earn.state.EarnListUM @@ -19,16 +18,13 @@ internal class UpdateGlobalFeedStateTransformer( private val earnResult: EarnTopToken?, private val onRetryClicked: () -> Unit, private val analyticsEventHandler: AnalyticsEventHandler, - private val feedFeatureToggle: FeedFeatureToggle, ) : FeedListUMTransformer { override fun transform(prevState: FeedListUM): FeedListUM { val blockStates = buildList { add(getNewsState(prevState)) add(getChartsState()) - if (feedFeatureToggle.isEarnBlockEnabled) { - add(getEarnState(prevState)) - } + add(getEarnState(prevState)) } val newGlobalState = when { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 85cb23c471..2c3c85e529 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -3,8 +3,13 @@ package com.tangem.features.feed.model.market.details import androidx.compose.runtime.Stable import arrow.core.Either import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.TangemSiteShareUrlBuilder +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartDataProducer import com.tangem.common.ui.charts.state.sorted @@ -21,6 +26,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.format.bigdecimal.fiat @@ -41,6 +47,8 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.AddToPortfolioSlotRoute import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent import com.tangem.features.feed.impl.R @@ -79,6 +87,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getUserCountryUseCase: GetUserCountryUseCase, paramsContainer: ParamsContainer, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val designFeatureToggles: DesignFeatureToggles, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, @@ -91,10 +100,12 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val urlOpener: UrlOpener, private val getNewsUseCase: GetNewsUseCase, private val shareManager: ShareManager, + private val appRouter: AppRouter, ) : Model() { private val quotesJob = JobHolder() private var userCountry: UserCountry? = null + private var isScrollToSectionHandled = false private val params = paramsContainer.require() private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token) @@ -221,6 +232,18 @@ internal class MarketsTokenDetailsModel @Inject constructor( val isVisibleOnScreen = MutableStateFlow(false) val networksState = MutableStateFlow(TokenNetworksState.Loading) + val addToPortfolioSheetNavigation = SlotNavigation() + + private val isAddToPortfolioAvailable: Boolean = + params.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings( + shouldSkipTokenActionsScreen = false, + ), + analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source), + ) + val state = MutableStateFlow( MarketsTokenDetailsUM( tokenName = params.token.name, @@ -297,6 +320,70 @@ internal class MarketsTokenDetailsModel @Inject constructor( initialLoad() loadRelatedNews() + + if (params.shouldOpenExchanges) { + modelScope.launch { + val exchangesCount = params.exchangesCount + ?: currentTokenInfo.value?.exchangesAmount + ?: 0 + onListedOnClick(exchangesCount) + } + } + + modelScope.launch { + networksState.collect { tokenNetworkState -> + val manager = addToPortfolioManager + when (tokenNetworkState) { + is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(tokenNetworkState.networks) + TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList()) + else -> Unit + } + } + } + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { addToPortfolioSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { addToPortfolioSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { result -> + addToPortfolioSheetNavigation.dismiss() + openTokenDetails(result) + } + .launchIn(modelScope) + } + + fun openAddToPortfolio() { + if (!isAddToPortfolioAvailable) return + prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.DirectAdd) + addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute) + } + + fun openAddToPortfolioViaUserPortfolio() { + if (!isAddToPortfolioAvailable) return + prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.ViaUserPortfolio) + addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute) + } + + private fun openTokenDetails(result: AddToPortfolioManager.Result) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) + } + + private fun prepareAddToPortfolioManager(launchMode: AddToPortfolioManager.LaunchMode) { + val manager = addToPortfolioManager + manager.setTokenParams(params.token) + manager.updateLaunchMode(launchMode) + when (val network = networksState.value) { + is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(network.networks) + TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList()) + else -> Unit + } } private fun initialLoad() { @@ -324,7 +411,6 @@ internal class MarketsTokenDetailsModel @Inject constructor( getNewsUseCase.getNews( limit = RELATED_NEWS_LIMIT, newsListConfig = NewsListConfig( - language = Locale.getDefault().language, snapshot = null, tokenIds = listOf(params.token.id.value), ), @@ -519,6 +605,14 @@ internal class MarketsTokenDetailsModel @Inject constructor( description = descriptionConverter.convert(newInfo), infoBlocks = infoConverter.convert(newInfo), ), + scrollToSection = if (!isScrollToSectionHandled) { + mapSectionToKey(params.preselectedSection)?.let { key -> + isScrollToSectionHandled = true + triggeredEvent(data = key, onConsume = ::consumeScrollToSection) + } ?: consumedEvent() + } else { + marketsTokenDetailsUM.scrollToSection + }, ) } @@ -526,7 +620,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( val networks = newInfo.networks?.filter { network -> BlockchainUtils.isSupportedNetworkId( - blockchainId = network.networkId, + networkId = network.networkId, excludedBlockchains = excludedBlockchains, hotExcludedBlockchains = hotWalletExcludedBlockchains, hasOnlyHotWallets = isAllWalletsIsHot, @@ -755,6 +849,17 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) } + private fun mapSectionToKey(section: PreselectedTokenDetailsSection?): String? { + return when (section) { + PreselectedTokenDetailsSection.News -> MarketsTokenDetailsUM.RelatedNews.SECTION_KEY + null -> null + } + } + + private fun consumeScrollToSection() { + state.update { it.copy(scrollToSection = consumedEvent()) } + } + private companion object { const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L const val RELATED_NEWS_LIMIT = 10 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 11a933c2f1..dfd35cd4a7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -67,9 +68,10 @@ internal class MarketsListModel @Inject constructor( onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) }, shouldAlwaysShowSearchBar = Provider { modelParams.params.shouldAlwaysShowSearchBar }, preselectedSortType = Provider { modelParams.params.preselectedSortType }, + preselectedInterval = Provider { modelParams.params.preselectedInterval }, onBackClick = modelParams.clickIntents.onBackClicked, analyticsEventHandler = analyticsEventHandler, - onSearchBarClick = modelParams.clickIntents.onSearchClicked, + onSearchBarClick = { modelParams.clickIntents.onSearchClicked(AnalyticsParam.ScreensSources.Market.value) }, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt index 781dceeb6d..c7b455abfe 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -141,6 +141,21 @@ internal class MarketsListBatchFlowManager( initialValue = false, ) + val initialLoadingError: Flow = batchFlow.state + .map { it.status } + .distinctUntilChanged() + .filterIsInstance() + .map { it.throwable } + + val totalCount: StateFlow = batchFlow.state + .map { it.totalCount } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + val isSearchNotFoundState = batchFlow.state .map { batchListState -> currentSearchText().isNullOrEmpty().not() && diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt index fe1b3b15a1..473c06b89b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -27,6 +27,7 @@ internal class MarketsListUMStateManager( private val shouldAlwaysShowSearchBar: Provider, private val currentVisibleIds: Provider>, private val preselectedSortType: Provider, + private val preselectedInterval: Provider, private val onLoadMoreUiItems: () -> Unit, private val visibleItemsChanged: (itemsKeys: List) -> Unit, private val onRetryButtonClicked: () -> Unit, @@ -229,7 +230,7 @@ internal class MarketsListUMStateManager( shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(), ), selectedSortBy = preselectedSortType(), - selectedInterval = MarketsListUM.TrendInterval.H24, + selectedInterval = preselectedInterval(), onIntervalClick = { selectedInterval = it }, onSortByButtonClick = { isSortByBottomSheetShown = true }, sortByBottomSheet = TangemBottomSheetConfig( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index 8c816de944..15677f60a6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -32,12 +32,11 @@ import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger -import java.util.Locale import javax.inject.Inject @Stable @@ -60,14 +59,12 @@ internal class NewsDetailsModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val currentLanguage = Locale.getDefault().language private val newsDetailsConverter = NewsDetailsConverter(onRelatedArticleClick = ::onRelatedArticleClick) private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config -> NewsDetailsPaginationManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, - currentLanguage = Provider { config.language }, currentCategoryIds = Provider { config.categoryIds }, modelScope = modelScope, dispatchers = dispatchers, @@ -212,10 +209,7 @@ internal class NewsDetailsModel @Inject constructor( } private suspend fun initialPrefetch() { - observeNewsDetailsUseCase.prefetch( - newsIds = params.preselectedArticlesId, - language = currentLanguage, - ).onLeft { errors -> + observeNewsDetailsUseCase.prefetch(newsIds = params.preselectedArticlesId).onLeft { errors -> errors.onEach { (newsId, error) -> when { // an article is opened from deeplink and is not found diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt index 7ec0b5f531..359d174d80 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class NewsDetailsPaginationManager( private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, - private val currentLanguage: Provider, getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, dispatchers: CoroutineDispatcherProvider, currentCategoryIds: Provider>, @@ -23,7 +22,6 @@ internal class NewsDetailsPaginationManager( isRedesignEnabled: Boolean, ) : NewsListBatchFlowManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, - currentLanguage = currentLanguage, currentCategoryIds = currentCategoryIds, modelScope = modelScope, dispatchers = dispatchers, @@ -53,10 +51,7 @@ internal class NewsDetailsPaginationManager( .distinctUntilChanged() .collect { newIds -> if (newIds.isNotEmpty()) { - observeNewsDetailsUseCase.prefetch( - newsIds = newIds, - language = currentLanguage(), - ) + observeNewsDetailsUseCase.prefetch(newsIds = newIds) _cachedPrefetchedIds.update { it + newIds } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt index a966da27f2..d2cdcd64d7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt @@ -4,23 +4,17 @@ import androidx.compose.runtime.Stable import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.FormattedDate -import com.tangem.core.ui.utils.getFormattedDate +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.DetailedArticle import com.tangem.domain.models.news.RelatedArticle -import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.Media import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM -import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import org.joda.time.DateTime @Stable internal class NewsDetailsConverter( @@ -74,34 +68,4 @@ internal class NewsDetailsConverter( ) }.toImmutableList() } - - private fun mapFormattedDate(createdAt: String): TextReference { - val formattedDate = getFormattedDate( - createdAt = createdAt, - now = DateTime.now(), - ) - return when (formattedDate) { - is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) - is FormattedDate.HoursAgo -> TextReference.PluralRes( - id = R.plurals.news_published_hours_ago, - count = formattedDate.hours, - formatArgs = wrappedList(formattedDate.hours), - ) - is FormattedDate.MinutesAgo -> TextReference.PluralRes( - id = R.plurals.news_published_minutes_ago, - count = formattedDate.minutes, - formatArgs = wrappedList(formattedDate.minutes), - ) - is FormattedDate.Today -> TextReference.Combined( - refs = WrappedList( - data = listOf( - TextReference.Res(R.string.common_today), - TextReference.Str(StringsSigns.COMA_SIGN), - TextReference.Str(StringsSigns.WHITE_SPACE), - TextReference.Str(formattedDate.time), - ), - ), - ) - } - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index 9542f5be8a..483ad0271e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -6,18 +6,19 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import com.tangem.features.feed.model.news.list.analytics.NewsListAnalyticsEvent -import com.tangem.features.feed.model.news.list.statemanager.NewsListStateManager import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.features.feed.model.news.list.statemanager.NewsListStateManager import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM import com.tangem.utils.Provider -import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -40,8 +41,7 @@ internal class NewsListModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val selectedCategoryId = MutableStateFlow(null) - private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() + private val selectedCategoryId = MutableStateFlow(params.preselectedCategoryId) private val categoriesLoader by lazy { NewsCategoriesLoader( @@ -54,7 +54,6 @@ internal class NewsListModel @Inject constructor( private val batchFlowManager by lazy { NewsListBatchFlowManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, - currentLanguage = Provider { currentLanguage }, currentCategoryIds = Provider { selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty() }, @@ -70,7 +69,7 @@ internal class NewsListModel @Inject constructor( private val _state = MutableStateFlow( NewsListUM( - selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, + selectedCategoryId = params.preselectedCategoryId ?: DEFAULT_ALL_NEWS_CATEGORIES_ID, filters = persistentListOf(), newsListState = NewsListState.Loading, listOfArticles = persistentListOf(), @@ -87,20 +86,38 @@ internal class NewsListModel @Inject constructor( val state = _state.asStateFlow() init { - loadCategories() observeNewsList() - batchFlowManager.reload() + loadCategories() } private fun loadCategories() { modelScope.launch(dispatchers.default) { val filterChips = categoriesLoader.load() + val validIds = filterChips.mapTo(mutableSetOf()) { it.id } + selectedCategoryId.value = selectedCategoryId.value?.takeIf { it in validIds } + val effectiveId = selectedCategoryId.value ?: DEFAULT_ALL_NEWS_CATEGORIES_ID + val scrollIndex = filterChips.indexOfFirst { it.id == effectiveId }.takeIf { it > 0 } _state.update { currentState -> - currentState.copy(filters = filterChips) + currentState.copy( + selectedCategoryId = effectiveId, + filters = filterChips.map { chip -> + chip.copy(isSelected = chip.id == effectiveId) + }.toImmutableList(), + scrollToCategoryEvent = if (scrollIndex != null) { + triggeredEvent(data = scrollIndex, onConsume = ::onScrollToCategoryConsumed) + } else { + currentState.scrollToCategoryEvent + }, + ) } + batchFlowManager.reload() } } + private fun onScrollToCategoryConsumed() { + _state.update { it.copy(scrollToCategoryEvent = consumedEvent()) } + } + private fun observeNewsList() { modelScope.launch(dispatchers.default) { combine( @@ -114,7 +131,6 @@ internal class NewsListModel @Inject constructor( paginationStatus = paginationStatus, onRetryClick = { loadCategories() - batchFlowManager.reload() }, onLoadMore = { batchFlowManager.loadMore() }, ) @@ -158,7 +174,6 @@ internal class NewsListModel @Inject constructor( private fun createNewsListConfig(): NewsListConfig { return NewsListConfig( - language = currentLanguage, snapshot = null, tokenIds = emptyList(), categoryIds = selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty(), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt index a6e5d5a2fd..03565cab2b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt @@ -20,11 +20,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -@Suppress("LongParameterList") internal open class NewsListBatchFlowManager( private val isRedesignEnabled: Boolean, getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, - private val currentLanguage: Provider, private val currentCategoryIds: Provider>, protected val modelScope: CoroutineScope, protected val dispatchers: CoroutineDispatcherProvider, @@ -146,7 +144,6 @@ internal open class NewsListBatchFlowManager( private fun createNewsListConfig(): NewsListConfig { return NewsListConfig( - language = currentLanguage(), snapshot = null, tokenIds = emptyList(), categoryIds = currentCategoryIds(), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index 2273f437bb..28d4ac7e21 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -1,65 +1,72 @@ package com.tangem.features.feed.model.search import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.common.ui.charts.state.MarketChartData -import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter -import com.tangem.common.ui.charts.state.sorted -import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase -import com.tangem.domain.markets.GetTokenPriceChartUseCase -import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.domain.models.account.AccountName +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.markets.* +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase import com.tangem.domain.search.usecase.GetSearchResultsUseCase import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase import com.tangem.domain.search.usecase.SaveSearchQueryUseCase import com.tangem.features.feed.components.search.DefaultSearchComponent +import com.tangem.features.feed.components.search.SearchBottomSheetRoute import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager -import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecentSearchTokenConverter -import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency -import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter -import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency +import com.tangem.features.feed.model.search.analytics.SearchAnalyticsHelper +import com.tangem.features.feed.model.search.converter.* import com.tangem.features.feed.model.search.state.SearchStateController import com.tangem.features.feed.model.search.state.transformers.* -import com.tangem.features.feed.ui.search.state.* +import com.tangem.features.feed.ui.search.state.MarketSearchResultUM +import com.tangem.features.feed.ui.search.state.SearchContentUM +import com.tangem.features.feed.ui.search.state.SearchUM +import com.tangem.features.feed.ui.search.state.TextHintItemUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L private const val MARKET_SEARCH_DEBOUNCE_MS = 500L +private const val RESULTS_SHOWN_DEBOUNCE_MS = 1000L -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class SearchModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, paramsContainer: ParamsContainer, getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSearchResultsUseCase: GetSearchResultsUseCase, private val saveSearchQueryUseCase: SaveSearchQueryUseCase, private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase, private val clearSearchHistoryUseCase: ClearSearchHistoryUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val appRouter: AppRouter, private val stateController: SearchStateController, + private val searchAnalyticsHelper: SearchAnalyticsHelper, ) : Model() { private val params = paramsContainer.require() @@ -68,7 +75,6 @@ internal class SearchModel @Inject constructor( private val searchResultsJob = JobHolder() private val marketSearchDebounceJob = JobHolder() private var shouldShowAllTokensIncludingUnder100k = false - private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } }.stateIn( @@ -77,6 +83,13 @@ internal class SearchModel @Inject constructor( initialValue = AppCurrency.Default, ) + private val isBalanceHidden = getBalanceHidingSettingsUseCase.isBalanceHidden() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + private val marketsListItemToRecentSearchTokenConverter by lazy { MarketsListItemUMToRecentSearchTokenConverter() } @@ -102,6 +115,8 @@ internal class SearchModel @Inject constructor( ) } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val state: StateFlow get() = stateController.uiState init { @@ -110,7 +125,10 @@ internal class SearchModel @Inject constructor( subscribeToMarketUiItems() subscribeToQuotesPolling() subscribeToAppCurrencyChanges() + subscribeToMarketLoadingErrors() + subscribeToResultsShown() loadHistory() + searchAnalyticsHelper.sendSearchScreenOpened(params.sourceParams) } fun loadMore() { @@ -126,11 +144,14 @@ internal class SearchModel @Inject constructor( fun clearSearchHistory() { modelScope.launch(dispatchers.default) { clearSearchHistoryUseCase() + searchAnalyticsHelper.sendClearButtonClicked() } } fun onTextHintClick(text: String) { stateController.update(UpdateSearchBarQueryTransformer(text)) + searchAnalyticsHelper.sendHintClicked(text) + searchAnalyticsHelper.sendSearchStarted() } fun onResultMarketTokenClick(item: MarketsListItemUM) { @@ -143,17 +164,40 @@ internal class SearchModel @Inject constructor( ) saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input)) saveSearchQueryUseCase(stateController.value.searchBar.query) + searchAnalyticsHelper.sendMarketItemClicked(item.currencySymbol) + withContext(dispatchers.mainImmediate) { + searchMarketsListManager.getTokenById(item.id)?.let { found -> + params.onMarketTokenClick(found.toSerializableParam(), appCurrency) + } + } } } + fun onHistoryTokenClick(item: MarketsListItemUM) { + val tokenMarketParams = TokenMarketParams( + id = item.id, + name = item.name, + symbol = item.currencySymbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = item.price.fiatPrice, + h24Percent = null, + weekPercent = null, + monthPercent = null, + ), + imageUrl = item.iconUrl, + ) + searchAnalyticsHelper.sendRecentItemClicked(item.currencySymbol) + params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value) + } + private fun initCallbacks() { stateController.update(object : SearchUMTransformer { override fun transform(prevState: SearchUM): SearchUM { return prevState.copy( searchBar = prevState.searchBar.copy( onQueryChange = ::onQueryChange, - onActiveChange = ::onActiveChange, onClearClick = ::onClearClick, + onCancelClick = params.onBackClick, ), ) } @@ -162,16 +206,41 @@ internal class SearchModel @Inject constructor( private fun onQueryChange(query: String) { stateController.update(UpdateSearchBarQueryTransformer(query)) - } - - private fun onActiveChange(isActive: Boolean) { - if (!isActive) params.onBackClick() + searchAnalyticsHelper.sendSearchStarted() } private fun onClearClick() { stateController.update(UpdateSearchBarQueryTransformer("")) } + private fun onSingleUserAssetClick(entry: UserAssetEntry) { + searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol) + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = entry.userWalletId, + currency = entry.currencyStatus.currency, + ), + ) + } + + private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) { + searchAnalyticsHelper.sendGroupClicked(grouped.tokenSymbol) + bottomSheetNavigation.activate( + SearchBottomSheetRoute.TokenSelector( + entries = grouped.entries, + appCurrency = currentAppCurrency.value, + isBalanceHidden = isBalanceHidden.value, + onTokenSelected = ::onTokenSelectedFromGroup, + onDismiss = { bottomSheetNavigation.dismiss() }, + ), + ) + } + + private fun onTokenSelectedFromGroup(entry: UserAssetEntry) { + bottomSheetNavigation.dismiss() + onSingleUserAssetClick(entry) + } + private fun subscribeToQueryChanges() { stateController.uiState .map { it.searchBar.query.trim() } @@ -200,20 +269,21 @@ internal class SearchModel @Inject constructor( private fun subscribeToSearchResults(query: String) { modelScope.launch { - getSearchResultsUseCase(query = query).collectLatest { searchResult -> - val userAssets = searchResult.userAssets.map { entry -> - UserAssetItemUM( - id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" + - "_${entry.currencyStatus.currency.id.value}", - tokenIconUrl = entry.currencyStatus.currency.iconUrl, - tokenName = entry.currencyStatus.currency.name, - tokenSymbol = entry.currencyStatus.currency.symbol, - accountName = entry.accountName.toDisplayString(), - onClick = { - // TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. - }, - ) - }.toImmutableList() + combine( + getSearchResultsUseCase(query = query), + currentAppCurrency, + isBalanceHidden, + ) { searchResult, appCurrency, balanceHidden -> + val converter = UserAssetSearchItemConverter( + appCurrency = appCurrency, + isBalanceHidden = balanceHidden, + onSingleClick = ::onSingleUserAssetClick, + onGroupedClick = ::onGroupedUserAssetClick, + ) + searchResult.userAssets + .map(converter::convert) + .toImmutableList() + }.collectLatest { userAssets -> stateController.update(UpdateUserAssetsTransformer(userAssets)) } }.saveIn(searchResultsJob) @@ -235,6 +305,50 @@ internal class SearchModel @Inject constructor( }.launchIn(modelScope) } + private fun subscribeToMarketLoadingErrors() { + searchMarketsListManager.initialLoadingError + .onEach { throwable -> + val (code, message) = when (throwable) { + is ApiResponseError.HttpException -> + throwable.code.numericCode to throwable.message.orEmpty() + else -> null to throwable.message.orEmpty() + } + searchAnalyticsHelper.sendErrorMarketsData(code, message) + } + .launchIn(modelScope) + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + private fun subscribeToResultsShown() { + stateController.uiState + .map { it.searchBar.query.trim() } + .distinctUntilChanged() + .flatMapLatest { query -> + if (query.isEmpty()) { + emptyFlow() + } else { + combine( + searchMarketsListManager.totalCount.filterNotNull(), + stateController.uiState.map { state -> + (state.content as? SearchContentUM.Results)?.userAssets?.size ?: 0 + }.distinctUntilChanged(), + ) { marketCount, userAssetsCount -> + marketCount to userAssetsCount + } + .debounce(RESULTS_SHOWN_DEBOUNCE_MS) + .take(1) + } + } + .onEach { (marketCount, userAssetsCount) -> + searchAnalyticsHelper.sendResultShown( + totalResultsCount = marketCount + userAssetsCount, + marketsResultsCount = marketCount, + userTokensResultsCount = userAssetsCount, + ) + } + .launchIn(modelScope) + } + private fun subscribeToMarketUiItems() { combine( flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(), @@ -256,6 +370,7 @@ internal class SearchModel @Inject constructor( ) } .filterNotNull() + .distinctUntilChanged() .onEach { snapshot -> stateController.update(ApplySearchMarketBatchTransformer(snapshot)) } @@ -334,13 +449,6 @@ internal class SearchModel @Inject constructor( } } - private fun AccountName.toDisplayString(): String { - return when (this) { - is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize - is AccountName.Custom -> value - } - } - private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { launch { while (true) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt new file mode 100644 index 0000000000..7111311a4d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.model.search + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.feed.components.search.SearchTokenSelectorComponent +import com.tangem.features.feed.model.search.state.TokenSelectorStateController +import com.tangem.features.feed.model.search.state.transformers.BuildTokenSelectorSectionsTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class SearchTokenSelectorModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val stateController: TokenSelectorStateController, +) : Model() { + + private val params = paramsContainer.require() + + val state: StateFlow + get() = stateController.uiState + + init { + stateController.update( + BuildTokenSelectorSectionsTransformer( + entries = params.entries, + appCurrency = params.appCurrency, + isBalanceHidden = params.isBalanceHidden, + onTokenSelected = params.onTokenSelected, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsEvent.kt new file mode 100644 index 0000000000..9abc00632c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsEvent.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.model.search.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR + +internal sealed class SearchAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Search", event = event, params = params) { + + data class SearchScreenOpened( + private val screensSource: String, + ) : SearchAnalyticsEvent( + event = "Search Screen Opened", + params = mapOf(SOURCE to screensSource), + ) + + class SearchStarted : SearchAnalyticsEvent(event = "Search Started") + + data class ResultsShown( + private val totalResultsCount: Int, + private val marketsResultsCount: Int, + private val userTokensResultsCount: Int, + ) : SearchAnalyticsEvent( + event = "Results Shown", + params = mapOf( + "Total Results" to totalResultsCount.toString(), + "User Tokens Count" to userTokensResultsCount.toString(), + "Market Tokens Count" to marketsResultsCount.toString(), + ), + ) + + data class ErrorMarketsData( + private val code: Int?, + private val message: String, + ) : SearchAnalyticsEvent( + event = "Error - Markets Data", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class PortfolioItemClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Portfolio Item Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + data class HintClicked( + private val hint: String, + ) : SearchAnalyticsEvent( + event = "Hint Clicked", + params = mapOf("Text" to hint), + ) + + data class RecentItemClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Recent Item Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + data class MarketItemClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Market Item Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + data class GroupClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Group Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + class ButtonClearHistoryClick : SearchAnalyticsEvent(event = "Button - Clear History") +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsHelper.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsHelper.kt new file mode 100644 index 0000000000..5a1f8d71a1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsHelper.kt @@ -0,0 +1,64 @@ +package com.tangem.features.feed.model.search.analytics + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import javax.inject.Inject + +class SearchAnalyticsHelper @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + private var isSearchStartedWasSent = false + + fun sendSearchScreenOpened(source: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.SearchScreenOpened(source)) + } + + fun sendMarketItemClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.MarketItemClicked(tokenSymbol)) + } + + fun sendClearButtonClicked() { + analyticsEventHandler.send(SearchAnalyticsEvent.ButtonClearHistoryClick()) + } + + fun sendHintClicked(text: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.HintClicked(text)) + } + + fun sendRecentItemClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.RecentItemClicked(tokenSymbol)) + } + + fun sendSearchStarted() { + if (isSearchStartedWasSent) return + analyticsEventHandler.send(SearchAnalyticsEvent.SearchStarted()) + isSearchStartedWasSent = true + } + + fun sendPortfolioItemClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.PortfolioItemClicked(tokenSymbol)) + } + + fun sendGroupClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.GroupClicked(tokenSymbol)) + } + + fun sendResultShown(totalResultsCount: Int, marketsResultsCount: Int, userTokensResultsCount: Int) { + analyticsEventHandler.send( + SearchAnalyticsEvent.ResultsShown( + totalResultsCount = totalResultsCount, + marketsResultsCount = marketsResultsCount, + userTokensResultsCount = userTokensResultsCount, + ), + ) + } + + fun sendErrorMarketsData(code: Int?, message: String) { + analyticsEventHandler.send( + SearchAnalyticsEvent.ErrorMarketsData( + code = code, + message = message, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt new file mode 100644 index 0000000000..a57bd66a42 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt @@ -0,0 +1,176 @@ +package com.tangem.features.feed.model.search.converter + +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.extensions.networkIconResId +import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.domain.search.model.UserAssetSearchItem +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +internal class UserAssetSearchItemConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onSingleClick: (UserAssetEntry) -> Unit, + private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit, +) : Converter { + + override fun convert(value: UserAssetSearchItem): UserAssetItemUM { + return when (value) { + is UserAssetSearchItem.Single -> convertSingle(value.entry) + is UserAssetSearchItem.Grouped -> convertGrouped(value) + } + } + + private fun convertSingle(entry: UserAssetEntry): UserAssetItemUM.Single { + val currency = entry.currencyStatus.currency + val value = entry.currencyStatus.value + + return UserAssetItemUM.Single( + id = "${entry.userWalletId.stringValue}_${entry.accountId.value}_${currency.id.value}", + icon = TangemIconUM.Currency( + currencyIconState = CryptoCurrencyToIconStateConverter().convert(entry.currencyStatus), + ), + tokenName = currency.name, + tokenSymbol = currency.symbol, + fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, + priceChangeState = when (value) { + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + -> PriceChangeState.Unknown + else -> PriceChangeState.Content( + type = PriceChangeType.fromBigDecimal(value.priceChange.orZero()), + valueInPercent = value.priceChange.format { percent() }, + ) + }, + balanceState = convertSingleBalanceState(value, currency.symbol, currency.decimals), + isBalanceHidden = isBalanceHidden, + onClick = { onSingleClick(entry) }, + networkName = entry.currencyStatus.currency.network.name, + ) + } + + private fun convertSingleBalanceState( + value: CryptoCurrencyStatus.Value, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + return when { + value is CryptoCurrencyStatus.Loading && value.amount != null -> + BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated( + appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol, + ) ?: stringReference(StringsSigns.DASH_SIGN), + ) + value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading + value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable + value.isError && value.amount != null -> + BalanceDisplayState.Stale( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated( + appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol, + ) ?: stringReference(StringsSigns.DASH_SIGN), + ) + value.isError -> BalanceDisplayState.Unreachable + else -> BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated( + appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol, + ) ?: stringReference(StringsSigns.DASH_SIGN), + ) + } + } + + private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped { + val firstCurrency = item.entries.first().currencyStatus.currency + + val entryCurrencyStatus = item.entries.first().currencyStatus + + return UserAssetItemUM.Grouped( + id = "grouped_${item.tokenName}_${item.tokenSymbol}", + icon = TangemIconUM.Currency( + currencyIconState = CurrencyIconState.CoinIcon( + url = entryCurrencyStatus.currency.iconUrl, + fallbackResId = entryCurrencyStatus.currency.networkIconResId, + isGrayscale = entryCurrencyStatus.currency.network.isTestnet || entryCurrencyStatus.value.isError, + shouldShowCustomBadge = entryCurrencyStatus.currency.isCustom, + ), + ), + tokenName = item.tokenName, + tokenSymbol = item.tokenSymbol, + tokensCount = item.entries.size, + balanceState = convertGroupedBalanceState(item.entries, firstCurrency.symbol, firstCurrency.decimals), + isBalanceHidden = isBalanceHidden, + onClick = { onGroupedClick(item) }, + ) + } + + private fun convertGroupedBalanceState( + entries: List, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + val hasAnyLoading = entries.any { it.currencyStatus.value is CryptoCurrencyStatus.Loading } + val hasAnyError = entries.any { it.currencyStatus.value.isError } + val hasAnyAmount = entries.any { it.currencyStatus.value.amount != null } + val isAllError = entries.all { it.currencyStatus.value.isError } + + val balance = when { + hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading + hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals) + isAllError -> BalanceDisplayState.Unreachable + hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable + hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals) + else -> computeGroupBalance(entries, symbol, decimals) + } + return balance + } + + private fun computeGroupBalance( + entries: List, + symbol: String, + decimals: Int, + ): BalanceDisplayState.Loaded { + val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO } + return BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)), + fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol), + ) + } + + private fun computeGroupBalanceFlickering( + entries: List, + symbol: String, + decimals: Int, + ): BalanceDisplayState.Flickering { + val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO } + return BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)), + fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol), + ) + } + + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { + return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt new file mode 100644 index 0000000000..6e3c068eeb --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt @@ -0,0 +1,25 @@ +package com.tangem.features.feed.model.search.state + +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class TokenSelectorStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow( + value = TokenSelectorContentUM(sections = persistentListOf()), + ) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: TokenSelectorUMTransformer) { + mutableUiState.update(function = transformer::transform) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt new file mode 100644 index 0000000000..c3d9e66e4d --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt @@ -0,0 +1,67 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.markets.tokenselector.AccountHeaderData +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM +import com.tangem.common.ui.markets.tokenselector.TokenSelectorSectionUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.portfolio.UserAssetEntry +import kotlinx.collections.immutable.toImmutableList + +internal class BuildTokenSelectorSectionsTransformer( + private val entries: List, + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenSelected: (UserAssetEntry) -> Unit, +) : TokenSelectorUMTransformer { + + private val entryConverter = TokenSelectorEntryConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenSelected = onTokenSelected, + ) + + override fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM { + return TokenSelectorContentUM(sections = buildSections().toImmutableList()) + } + + private fun buildSections(): List { + val sections = mutableListOf() + val byWallet = entries.groupBy { it.userWalletId } + val shouldShowWalletHeaders = byWallet.size > 1 + + for ((_, walletEntries) in byWallet) { + if (shouldShowWalletHeaders) { + sections.add( + TokenSelectorSectionUM.WalletHeader( + walletName = walletEntries.first().userWalletName, + ), + ) + } + + val byAccount = walletEntries.groupBy { it.accountId } + val shouldShowAccountHeaders = byAccount.size > 1 + + for ((_, accountEntries) in byAccount) { + val singles = entryConverter.convertList(accountEntries).toImmutableList() + val accountHeader = if (shouldShowAccountHeaders) { + val firstEntry = accountEntries.first() + AccountHeaderData( + accountName = firstEntry.accountName.toUM().value, + cryptoPortfolioIcon = firstEntry.accountIcon, + ) + } else { + null + } + sections.add( + TokenSelectorSectionUM.TokenGroup( + accountHeader = accountHeader, + items = singles, + ), + ) + } + } + + return sections + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt new file mode 100644 index 0000000000..7f6f59f367 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt @@ -0,0 +1,98 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +internal class TokenSelectorEntryConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenSelected: (UserAssetEntry) -> Unit, +) : Converter { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + override fun convert(value: UserAssetEntry): UserAssetItemUM.Single { + val currency = value.currencyStatus.currency + val currencyValue = value.currencyStatus.value + + return UserAssetItemUM.Single( + id = "${value.userWalletId.stringValue}_${value.accountId.value}_${currency.id.value}", + icon = TangemIconUM.Currency( + currencyIconState = iconConverter.convert(value.currencyStatus), + ), + tokenName = currency.name, + tokenSymbol = currency.symbol, + fiatRate = currencyValue.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, + priceChangeState = when (currencyValue) { + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + -> PriceChangeState.Unknown + else -> PriceChangeState.Content( + type = PriceChangeType.fromBigDecimal(currencyValue.priceChange.orZero()), + valueInPercent = currencyValue.priceChange.format { percent() }, + ) + }, + balanceState = convertBalanceState(currencyValue, currency.symbol, currency.decimals), + isBalanceHidden = isBalanceHidden, + onClick = { onTokenSelected(value) }, + networkName = value.currencyStatus.currency.network.name, + ) + } + + private fun convertBalanceState( + value: CryptoCurrencyStatus.Value, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + return when { + value is CryptoCurrencyStatus.Loading && value.amount != null -> + BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading + value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable + value.isError && value.amount != null -> + BalanceDisplayState.Stale( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value.isError -> BalanceDisplayState.Unreachable + else -> BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + } + } + + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { + return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt new file mode 100644 index 0000000000..480f20b319 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM + +internal interface TokenSelectorUMTransformer { + fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt index d99fdf0e7c..91e0fccbbe 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt @@ -1,9 +1,9 @@ package com.tangem.features.feed.model.search.state.transformers +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.features.feed.ui.search.state.MarketSearchResultUM import com.tangem.features.feed.ui.search.state.SearchContentUM import com.tangem.features.feed.ui.search.state.SearchUM -import com.tangem.features.feed.ui.search.state.UserAssetItemUM import kotlinx.collections.immutable.ImmutableList internal class UpdateUserAssetsTransformer( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index eaf7f1665d..d0bf1b73b9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -17,6 +17,7 @@ import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled @@ -130,6 +131,12 @@ private fun EntryContentV2( child.instance.Content( modifier = Modifier .fillMaxSize() + .conditionalCompose( + condition = !isOpenedInBottomSheet, + modifier = { + padding(top = topBarHeight) + }, + ) .hazeSourceTangem(zIndex = 0f, state = hazeState), contentPadding = PaddingValues(top = topBarHeight), bottomSheetState = bottomSheetState, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt index ed671b018a..abef06881d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt @@ -30,7 +30,7 @@ internal fun MetricsCard( modifier = modifier .background( color = cardColor, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), + shape = RoundedCornerShape(TangemTheme.dimens2.x6), ) .conditional( condition = onClick != null, @@ -67,7 +67,7 @@ private fun MetricsCardPreview() { content = { Text( text = "Market cap", - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.tertiary, ) }, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt index 56cb167e10..b4b2087cad 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt @@ -1,14 +1,12 @@ package com.tangem.features.feed.ui.earn.components import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme @Composable @@ -17,12 +15,7 @@ internal fun CardFilterBlock(modifier: Modifier = Modifier, content: @Composable modifier = modifier .fillMaxWidth() .background( - color = TangemTheme.colors2.surface.level2, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), - ) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, + color = TangemTheme.colors2.surface.level3, shape = RoundedCornerShape(TangemTheme.dimens2.x5), ), content = content, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt index dff1428685..d89e5a05a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt @@ -28,7 +28,7 @@ internal inline fun EarnFilterBotto TangemBottomSheet( config = config, type = TangemBottomSheetType.Modal, - containerColor = TangemTheme.colors2.surface.level3, + containerColor = TangemTheme.colors2.surface.level2, title = { TangemTopBar( title = resourceReference(R.string.earn_filter_by), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt index befac566af..50ffe3073a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt @@ -151,7 +151,16 @@ private fun NetworksTypesBlock( addDefaultPadding = false, ) .clickable { onOptionClick(item) }, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), + contentPadding = PaddingValues( + start = TangemTheme.dimens2.x3, + end = TangemTheme.dimens2.x3, + top = if (index == 0) 18.dp else TangemTheme.dimens2.x3, + bottom = if (index == allMyNetworks.lastIndex) { + 18.dp + } else { + TangemTheme.dimens2.x3 + }, + ), ) { Text( modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), @@ -190,7 +199,7 @@ private fun SpecificNetworksBlock( .padding(horizontal = 16.dp) .padding(top = 16.dp, bottom = 8.dp), text = stringResourceSafe(id = R.string.earn_filter_networks), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt index 9c8a7d4381..6b6da63857 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt @@ -117,13 +117,15 @@ private fun ContentV2(content: EarnFilterByTypeBottomSheetContentUM) { overflow = TextOverflow.Ellipsis, ) - TangemCheckbox( - modifier = Modifier - .padding(start = 8.dp) - .layoutId(layoutId = TangemRowLayoutId.TAIL), - isChecked = type == content.selectedOption, - onCheckedChange = { content.onOptionClick(type) }, - ) + if (type == content.selectedOption) { + TangemCheckbox( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + isChecked = true, + onCheckedChange = { content.onOptionClick(type) }, + ) + } } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt index 1acfb25681..e1e90b4328 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt @@ -3,6 +3,7 @@ package com.tangem.features.feed.ui.earn.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -46,7 +47,7 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier OpportunitiesBG( modifier = modifier .width(178.dp) - .clip(TangemTheme.shapes.roundedCornersXMedium) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .clickable(onClick = onClick), icon = TangemIconUM.Currency(item.currencyIconState), ) { @@ -76,7 +77,7 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier Text( text = item.symbol.resolveReference(), color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, ) } @@ -86,7 +87,7 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier Text( text = item.earnValue.resolveReference(), color = TangemTheme.colors2.text.status.positive, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index e69bccddd0..3f80f6214c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.platform.testTag @@ -21,6 +22,7 @@ import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.BaseSearchBarTestTags.SEARCH_BAR import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.components.FeedSearchBar @@ -159,4 +161,14 @@ private fun FeedListPreview() { TangemThemePreview { FeedList(state = createFeedPreviewState(), contentPadding = PaddingValues()) } +} + +@Preview(showBackground = true, heightDp = 1500) +@Composable +private fun FeedListPreviewV2() { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + FeedList(state = createFeedPreviewState(), contentPadding = PaddingValues()) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt index 32a6229d2e..5c9fc72db8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt @@ -32,7 +32,7 @@ internal fun ColumnScope.Header( ) { val isRedesignEnabled = LocalRedesignEnabled.current if (isRedesignEnabled) { - SpacerH(20.dp) + SpacerH(16.dp) } AnimatedContent(isLoading) { animatedState -> Row( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt index b38b335ae1..2c3ffe2bd8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt @@ -48,18 +48,17 @@ private fun DateBlockV2(currentDate: String) { Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), + .padding(horizontal = TangemTheme.dimens2.x6), text = stringResourceSafe(R.string.feed_market_and_news), - style = TangemTheme.typography2.headingRegular28, + style = TangemTheme.typography2.headingSemibold28, color = TangemTheme.colors2.text.neutral.primary, ) Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), + .padding(horizontal = TangemTheme.dimens2.x6), text = currentDate, style = TangemTheme.typography2.headingRegular28, color = TangemTheme.colors2.text.neutral.tertiary, ) - SpacerH(TangemTheme.dimens2.x6) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt index daf0b624ae..9bf3a68523 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -117,7 +118,7 @@ internal fun ColumnScope.MarketPulseBlock(marketChartConfig: MarketChartConfig, ) LazyRow( - modifier = Modifier.padding(vertical = if (isRedesignEnabled) 12.dp else 4.dp), + modifier = Modifier.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, contentPadding = if (isRedesignEnabled) { PaddingValues(horizontal = 16.dp, vertical = 6.dp) @@ -180,6 +181,11 @@ private fun Charts( TangemTheme.colors.background.action }, ), + shape = if (isRedesignEnabled) { + RoundedCornerShape(TangemTheme.dimens2.x6) + } else { + TangemTheme.shapes.roundedCornersXMedium + }, ) { Column(modifier = Modifier.fillMaxWidth()) { when (marketChart) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt index 443eff2ac5..84414ebab7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -37,12 +39,14 @@ import com.tangem.features.feed.ui.feed.state.* internal const val FOURTH_ITEM_INDEX = 3 private const val GRADIENT_START = 0f private const val GRADIENT_END = 0.5f -private const val LINEAR_GRADIENT_FIRST_PART_V2 = 0xFFA3A0FF -private const val LINEAR_GRADIENT_SECOND_PART_V2 = 0xFFF79DFF +private const val LINEAR_GRADIENT_FIRST_PART_V2 = 0xFF7B78FF +private const val LINEAR_GRADIENT_SECOND_PART_V2 = 0xFFC56BCD private const val LINEAR_GRADIENT_FIRST_PART_V1 = 0xFF635EEC private const val LINEAR_GRADIENT_SECOND_PART_V1 = 0xFFE05AED +private const val MAGIC_ICON_COLOR = 0xFF7D78FF + @Composable internal fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { AnimatedContent(news.newsUMState) { newsUMState -> @@ -102,10 +106,19 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, SpacerW(4.dp) - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20), - contentDescription = null, - ) + if (isRedesignEnabled) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x6), + tint = Color(MAGIC_ICON_COLOR), + imageVector = ImageVector.vectorResource(R.drawable.ic_magic_28), + contentDescription = null, + ) + } else { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20), + contentDescription = null, + ) + } SpacerW(2.dp) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt index 87a5053d61..866a07785d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt @@ -45,7 +45,7 @@ internal fun NewsSlider(newsSliderConfig: NewsSliderConfig) { articleConfigUM = article, onArticleClick = { newsSliderConfig.callbacks.onArticleClick(article.id) }, modifier = articleModifier - .width(228.dp) + .width(280.dp) .heightIn(min = 172.dp) .fillMaxHeight(), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), @@ -56,7 +56,7 @@ internal fun NewsSlider(newsSliderConfig: NewsSliderConfig) { item(contentType = "show_more") { ShowMoreArticlesCard( modifier = Modifier - .width(228.dp) + .width(280.dp) .heightIn(min = 172.dp) .onFirstVisible( minFractionVisible = 0.5f, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt index 56fa348c48..86b88e8137 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt @@ -34,6 +34,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalIsInDarkTheme import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet @@ -70,12 +71,15 @@ private fun TrendingArticle( onClick = onArticleClick, ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(TangemTheme.dimens2.x4), horizontalAlignment = Alignment.Start, ) { - DayAndRatingInfo(rating = stringReference("${articleConfigUM.score}")) + DayAndRatingInfo( + rating = stringReference("${articleConfigUM.score}"), + createdAt = articleConfigUM.createdAt, + ) - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x2) Text( text = articleConfigUM.title, @@ -88,17 +92,7 @@ private fun TrendingArticle( textAlign = TextAlign.Start, ) - SpacerH(18.dp) - - Text( - text = articleConfigUM.createdAt.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = TangemTheme.colors2.text.neutral.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SpacerH(18.dp) + SpacerH(TangemTheme.dimens2.x8) Tags(tags = articleConfigUM.tags.toImmutableList()) } @@ -111,14 +105,9 @@ internal fun ShowMoreArticlesCardV2(modifier: Modifier = Modifier, onClick: () - horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxSize() - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .background(color = TangemTheme.colors2.surface.level3) .clickable(onClick = onClick) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, - shape = RoundedCornerShape(20.dp), - ) .padding(vertical = 41.dp, horizontal = 16.dp), ) { Image( @@ -139,7 +128,7 @@ internal fun ShowMoreArticlesCardV2(modifier: Modifier = Modifier, onClick: () - Text( text = stringResourceSafe(R.string.news_stay_in_the_loop), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.secondary, ) } @@ -153,24 +142,20 @@ private fun DefaultArticle( ) { Column( modifier = modifier - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .background(color = TangemTheme.colors2.surface.level3) .clickable(onClick = onArticleClick) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, - shape = RoundedCornerShape(20.dp), - ) - .padding(16.dp), + .padding(TangemTheme.dimens2.x4), ) { Row(verticalAlignment = Alignment.CenterVertically) { RatingInfo( rating = stringReference("${articleConfigUM.score}"), isTrending = false, + createdAt = articleConfigUM.createdAt, ) } - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x2) Text( modifier = Modifier.weight(1f), @@ -185,17 +170,7 @@ private fun DefaultArticle( overflow = TextOverflow.Ellipsis, ) - SpacerH(8.dp) - - Text( - text = articleConfigUM.createdAt.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = TangemTheme.colors2.text.neutral.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x8) Tags(tags = articleConfigUM.tags.toImmutableList()) } @@ -220,7 +195,7 @@ private fun TrendingArticleBackground( Box( modifier = modifier - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .drawBehind { drawRect(bgColor) @@ -279,45 +254,61 @@ private fun TrendingArticleBackground( } @Composable -private fun DayAndRatingInfo(rating: TextReference, modifier: Modifier = Modifier) { +private fun DayAndRatingInfo(createdAt: TextReference, rating: TextReference, modifier: Modifier = Modifier) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { - RatingInfo(rating = rating, isTrending = true) + RatingInfo(rating = rating, isTrending = true, createdAt = createdAt) SpacerW(8.dp) Text( text = stringResourceSafe(R.string.feed_trending_now), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.primary, ) } } @Composable -private fun RatingInfo(rating: TextReference, isTrending: Boolean) { +private fun RatingInfo(rating: TextReference, isTrending: Boolean, createdAt: TextReference) { + val iconTint = if (isTrending) { + TangemTheme.colors2.fill.status.attention + } else { + TangemTheme.colors2.markers.iconGray + } + val captionColor = if (isTrending) { + TangemTheme.colors2.text.status.attention + } else { + TangemTheme.colors2.text.neutral.secondary + } + Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16), - tint = if (isTrending) { - TangemTheme.colors2.fill.status.attention - } else { - TangemTheme.colors2.markers.iconGray - }, + tint = iconTint, contentDescription = null, ) - SpacerW(2.dp) + SpacerW(TangemTheme.dimens2.x1) Text( text = rating.resolveReference(), - color = if (isTrending) { - TangemTheme.colors2.text.status.attention - } else { - TangemTheme.colors2.text.neutral.secondary - }, - style = TangemTheme.typography2.captionSemibold12, + color = captionColor, + style = TangemTheme.typography2.captionMedium12, + ) + + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x0_5), + text = StringsSigns.DOT, + color = captionColor, + style = TangemTheme.typography2.captionMedium12, + ) + + Text( + text = createdAt.resolveReference(), + color = captionColor, + style = TangemTheme.typography2.captionMedium12, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt index 90e3717cd0..530d0a1dc5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt @@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.feed.components.articles import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -12,22 +11,16 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM -import com.tangem.core.ui.ds.badge.TangemBadge -import com.tangem.core.ui.ds.badge.TangemBadgeColor -import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition -import com.tangem.core.ui.ds.badge.TangemBadgeShape -import com.tangem.core.ui.ds.badge.TangemBadgeSize -import com.tangem.core.ui.ds.badge.TangemBadgeType +import com.tangem.core.ui.ds.badge.* import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -100,7 +93,6 @@ private fun ArticleHeaderV1( } } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun ArticleHeaderV2( isTrending: Boolean, @@ -111,127 +103,121 @@ private fun ArticleHeaderV2( modifier: Modifier = Modifier, ) { Column(modifier = modifier) { - Row( - modifier = Modifier - .heightIn(min = 66.dp) - .padding(top = 16.dp), - verticalAlignment = Alignment.Bottom, - ) { - DateBlock( - modifier = Modifier.weight(1f), - createdAt = createdAt, - ) - SpacerW(30.dp) - VerticalDivider( - modifier = Modifier - .height(46.dp) - .padding(bottom = 4.dp), - color = TangemTheme.colors2.border.neutral.primary, - ) - SpacerW(30.dp) - ScoreBlock( - modifier = Modifier.weight(1f), - score = score, - isTrending = isTrending, - ) - } - - Text( - modifier = Modifier.padding(vertical = 36.dp), - text = title, - style = TangemTheme.typography2.headingBold34, - color = TangemTheme.colors2.text.neutral.primary, + ArticleHeaderV2MetaRow( + isTrending = isTrending, + score = score, + createdAt = createdAt, ) - - if (tags.isNotEmpty()) { - Spacer(modifier = Modifier.height(20.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - tags.forEach { tag -> - TangemBadge( - text = tag.text, - tangemIconUM = when (val content = tag.leadingContent) { - LabelLeadingContentUM.None -> null - is LabelLeadingContentUM.Token -> TangemIconUM.Url( - url = content.iconUrl, - fallbackRes = R.drawable.ic_alert_24, - ) - }, - shape = TangemBadgeShape.Rounded, - size = TangemBadgeSize.X9, - type = TangemBadgeType.Tinted, - color = TangemBadgeColor.Gray, - iconPosition = when (tag.leadingContent) { - LabelLeadingContentUM.None -> TangemBadgeIconPosition.None - is LabelLeadingContentUM.Token -> TangemBadgeIconPosition.Start - }, - ) - } - } - } + ArticleHeaderV2Title(title = title) + ArticleHeaderV2Tags(tags = tags) } } @Composable -private fun ScoreBlock(score: Float, isTrending: Boolean, modifier: Modifier = Modifier) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(10.dp), +private fun ArticleHeaderV2MetaRow(isTrending: Boolean, score: Float, createdAt: String) { + val starTint = if (isTrending) { + TangemTheme.colors2.fill.status.attention + } else { + TangemTheme.colors2.graphic.neutral.primary + } + val scoreColor = if (isTrending) { + TangemTheme.colors2.text.status.attention + } else { + TangemTheme.colors2.text.neutral.tertiary + } + + Row( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - modifier = Modifier.size(20.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16), - tint = if (isTrending) { - TangemTheme.colors2.fill.status.attention - } else { - TangemTheme.colors2.graphic.neutral.primary - }, - contentDescription = null, - ) - Text( - text = score.toString(), - style = TangemTheme.typography2.bodyRegular16, - color = if (isTrending) { - TangemTheme.colors2.text.status.attention - } else { - TangemTheme.colors2.text.neutral.primary - }, - ) - } + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x5), + imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16), + tint = starTint, + contentDescription = null, + ) Text( - text = stringResourceSafe(R.string.news_trending_score), - style = TangemTheme.typography2.captionSemibold13, + text = score.toString(), + style = TangemTheme.typography2.bodyMedium16, + color = scoreColor, + ) + Text( + text = StringsSigns.DOT, color = TangemTheme.colors2.text.neutral.tertiary, + style = TangemTheme.typography2.bodyMedium16, ) - } -} - -@Composable -private fun DateBlock(createdAt: String, modifier: Modifier = Modifier) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { Icon( modifier = Modifier.size(20.dp), imageVector = ImageVector.vectorResource(R.drawable.ic_calendar_20), - tint = TangemTheme.colors2.fill.neutral.primary, + tint = TangemTheme.colors2.graphic.neutral.secondary, contentDescription = null, ) Text( text = createdAt, - style = TangemTheme.typography2.captionSemibold13, color = TangemTheme.colors2.text.neutral.tertiary, + style = TangemTheme.typography2.bodyMedium16, ) } } +@Composable +private fun ArticleHeaderV2Title(title: String) { + Text( + modifier = Modifier + .padding( + top = TangemTheme.dimens2.x4, + bottom = TangemTheme.dimens2.x1_5, + start = TangemTheme.dimens2.x1, + ), + text = title, + style = TangemTheme.typography2.headingSemibold28, + color = TangemTheme.colors2.text.neutral.primary, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ArticleHeaderV2Tags(tags: ImmutableList) { + if (tags.isEmpty()) return + + Spacer(modifier = Modifier.height(TangemTheme.dimens2.x6)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + tags.forEach { tag -> + ArticleHeaderTagBadge(tag = tag) + } + } +} + +@Composable +private fun ArticleHeaderTagBadge(tag: LabelUM) { + TangemBadge( + text = tag.text, + tangemIconUM = labelLeadingIcon(tag.leadingContent), + shape = TangemBadgeShape.Rounded, + size = TangemBadgeSize.X9, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Gray, + iconPosition = labelLeadingBadgeIconPosition(tag.leadingContent), + ) +} + +private fun labelLeadingIcon(content: LabelLeadingContentUM): TangemIconUM? = when (content) { + LabelLeadingContentUM.None -> null + is LabelLeadingContentUM.Token -> TangemIconUM.Url( + url = content.iconUrl, + fallbackRes = R.drawable.ic_alert_24, + ) +} + +private fun labelLeadingBadgeIconPosition(content: LabelLeadingContentUM): TangemBadgeIconPosition = when (content) { + LabelLeadingContentUM.None -> TangemBadgeIconPosition.None + is LabelLeadingContentUM.Token -> TangemBadgeIconPosition.Start +} + @Preview(showBackground = true, widthDp = 360) @Composable private fun ArticleHeaderPreviewV1() { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index e3a0a86a28..ed2ebcdccb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -265,7 +265,7 @@ internal object FeedListPreviewDataProvider { } private fun createEarnListItemsUM(): ImmutableList { - return List(5) { + return List(1) { EarnListItemUM( network = stringReference("Ethereum"), symbol = stringReference("USDT"), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index 195e45cf2f..b84d7cde77 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -10,13 +10,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -27,11 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerH16 -import com.tangem.core.ui.components.SpacerH32 -import com.tangem.core.ui.components.SpacerH4 -import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons @@ -106,6 +96,13 @@ private fun Content( lazyListState = lazyListState, onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange, ) + EventEffect(state.scrollToSection) { targetKey -> + val targetIndex = lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == targetKey }?.index + if (targetIndex != null) { + lazyListState.animateScrollToItem(targetIndex) + } + } var bottomSpacing by remember { mutableStateOf(0.dp) } Box( @@ -128,7 +125,9 @@ private fun Content( .fillMaxWidth(), ) } - item { SpacerH16() } + item { + if (isRedesignEnabled) SpacerH(TangemTheme.dimens2.x3) else SpacerH16() + } item("intervalSelector") { IntervalSelector( trendInterval = state.selectedInterval, @@ -139,7 +138,9 @@ private fun Content( .fillMaxWidth(), ) } - item { SpacerH32() } + item { + if (isRedesignEnabled) SpacerH(TangemTheme.dimens2.x3) else SpacerH32() + } item("chart") { MarketTokenDetailsChart( modifier = Modifier.fillMaxWidth(), @@ -164,7 +165,7 @@ private fun Content( Modifier .onSizeChanged { size -> bottomSpacing = if (size.height > 0) { - with(density) { size.height.toDp() + 16.dp } + with(density) { size.height.toDp() } } else { 0.dp } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt index e6cfb24267..98b635eab6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow @@ -26,8 +25,6 @@ internal fun InformationTextBlock( text: TextReference, modifier: Modifier = Modifier, onInfoClick: (() -> Unit)? = null, - textColor: Color = TangemTheme.colors2.text.neutral.tertiary, - infoIconColor: Color = TangemTheme.colors2.markers.iconGray, informationTextBlockIconPosition: InformationTextBlockIconPosition = InformationTextBlockIconPosition.START, ) { val interactionSource = remember { MutableInteractionSource() } @@ -36,7 +33,7 @@ internal fun InformationTextBlock( Icon( modifier = Modifier.size(TangemTheme.dimens2.x4), imageVector = ImageVector.vectorResource(id = R.drawable.ic_information_24), - tint = infoIconColor, + tint = TangemTheme.colors2.fill.neutral.secondary, contentDescription = null, ) } @@ -44,8 +41,8 @@ internal fun InformationTextBlock( val contentText: @Composable () -> Unit = { Text( text = text.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = textColor, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt index 778af344f2..ff70bd80a1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt @@ -14,10 +14,10 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.components.buttons.chip.Chip import com.tangem.core.ui.components.inputrow.inner.DividerContainer -import com.tangem.core.ui.ds.badge.TangemBadge -import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition -import com.tangem.core.ui.ds.badge.TangemBadgeShape -import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -174,7 +174,7 @@ private fun SubBlockV2( Text( modifier = Modifier.padding(start = 10.dp, top = TangemTheme.dimens2.x4), text = title, - style = TangemTheme.typography2.bodySemibold16, + style = TangemTheme.typography2.headingSemibold20, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -185,16 +185,16 @@ private fun SubBlockV2( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), ) { links.fastForEach { link -> - TangemBadge( - text = stringReference(link.title), + SecondaryTangemButton( onClick = { onLinkClick(link) }, - iconPosition = TangemBadgeIconPosition.Start, + text = stringReference(link.title), + iconPosition = TangemButtonIconPosition.Start, tangemIconUM = TangemIconUM.Icon( iconRes = link.iconRes, - tintReference = { TangemTheme.colors2.markers.iconGray }, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, ), - size = TangemBadgeSize.X9, - shape = TangemBadgeShape.Rounded, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt index e7e041f634..a8f1472bd7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt @@ -94,18 +94,18 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) { }, title = { Row(verticalAlignment = Alignment.CenterVertically) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { Text( text = state.title.resolveReference(), - style = TangemTheme.typography2.headingSemibold20, - color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = state.description.resolveReference(), - style = TangemTheme.typography2.captionSemibold13, - color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -114,7 +114,7 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) { SpacerWMax() Icon( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(TangemTheme.dimens2.x5), imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24), tint = TangemTheme.colors2.markers.iconGray, contentDescription = null, @@ -165,7 +165,7 @@ internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) { radius = TangemTheme.dimens2.x25, ) TextShimmer( - style = TangemTheme.typography2.captionSemibold13, + style = TangemTheme.typography2.captionMedium13, modifier = Modifier.width(66.dp), radius = TangemTheme.dimens2.x25, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt index 966cf8e307..c61808563d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt @@ -42,7 +42,7 @@ import com.tangem.features.feed.ui.market.detailed.state.TrendingVolumeLiquidity internal fun MarketCapCard(item: InfoPointUMV2.MarketCap) { MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { MetricValueText(value = item.capitalizationValue) }, content = { @@ -66,7 +66,7 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) { MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { Row { @@ -74,7 +74,7 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) { Text( modifier = Modifier.padding(TangemTheme.dimens2.x1), text = stringResourceSafe(R.string.markets_token_details_trading_interval), - style = TangemTheme.typography2.captionSemibold11, + style = TangemTheme.typography2.captionMedium11, color = valueColor, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -97,24 +97,21 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) { SpacerH(12.dp) InformationTextBlock( text = resourceReference(R.string.markets_token_details_trading_volume), - textColor = tradingColor, - infoIconColor = tradingColor, onInfoClick = item.onInfoClick, ) } }, - cardColor = tradingColor.copy(alpha = .2f), + cardColor = TangemTheme.colors2.surface.level3, ) } @Composable internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) { - val ratingCardColor = mapRatingToCardColor(marketRatingType = item.marketRatingType) val ratingColor = mapRatingToColor(marketRatingType = item.marketRatingType) MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -140,13 +137,11 @@ internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) { SpacerH(12.dp) InformationTextBlock( text = resourceReference(R.string.markets_token_details_market_rating), - textColor = ratingColor, - infoIconColor = ratingColor, onInfoClick = item.onInfoClick, ) } }, - cardColor = ratingCardColor, + cardColor = TangemTheme.colors2.surface.level3, ) } @@ -154,7 +149,7 @@ internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) { internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { if (item.fullyDilutedValuationChange24 != null) { @@ -163,7 +158,7 @@ internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { Text( modifier = Modifier.padding(TangemTheme.dimens2.x1), text = stringResourceSafe(R.string.markets_token_details_trading_interval), - style = TangemTheme.typography2.captionSemibold11, + style = TangemTheme.typography2.captionMedium11, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -179,7 +174,7 @@ internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { Text( text = item.value?.resolveReference() ?: stringResourceSafe(R.string.token_market_metrics_no_data), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -200,14 +195,14 @@ internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) { MetricsCard( modifier = Modifier - .heightIn(min = if (item.fillValue == null) 88.dp else 114.dp) + .heightIn(min = if (item.fillValue == null) 88.dp else 106.dp) .fillMaxWidth(), title = { TangemRowContainer(contentPadding = PaddingValues(0.dp)) { Text( modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), text = stringResourceSafe(R.string.markets_token_details_circulating_supply), - style = TangemTheme.typography2.captionSemibold13, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -223,7 +218,7 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) { Text( modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), text = stringResourceSafe(R.string.markets_token_details_max_supply), - style = TangemTheme.typography2.captionSemibold13, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -235,7 +230,7 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) { .padding(top = 12.dp) .layoutId(TangemRowLayoutId.END_BOTTOM), text = item.maxValue.resolveReference(), - style = TangemTheme.typography2.headingSemibold22, + style = TangemTheme.typography2.headingSemibold20, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -270,7 +265,7 @@ private fun MetricValueText(value: TextReference?, modifier: Modifier = Modifier Text( modifier = modifier, text = value?.resolveReference() ?: stringResourceSafe(R.string.token_market_metrics_no_data), - style = TangemTheme.typography2.headingSemibold22, + style = TangemTheme.typography2.headingSemibold20, color = metricValueColor(hasData = value != null), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -338,7 +333,7 @@ private fun RatingChangeContent(iconRes: Int, iconTint: Color, changeValue: Stri SpacerW(2.dp) Text( text = changeValue, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = textColor, ) } @@ -366,14 +361,6 @@ private fun MarketRatingType.baseColor(): Color { @Composable private fun mapRatingToColor(marketRatingType: MarketRatingType): Color = marketRatingType.baseColor() -@Composable -private fun mapRatingToCardColor(marketRatingType: MarketRatingType): Color { - return when (marketRatingType) { - MarketRatingType.OTHER -> TangemTheme.colors2.surface.level3 - else -> marketRatingType.baseColor().copy(alpha = 0.3f) - } -} - // endregion private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt index dd7c78854c..ed8956d257 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt @@ -27,7 +27,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.components.ContainerWithDivider +import com.tangem.features.feed.ui.components.TokenMarketInformationBlock import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM @Composable @@ -80,45 +80,42 @@ private fun SecurityScoreBlockV1(state: SecurityScoreUM, modifier: Modifier = Mo @Composable private fun SecurityScoreBlockV2(state: SecurityScoreUM, modifier: Modifier = Modifier) { - ContainerWithDivider( + TokenMarketInformationBlock( modifier = modifier, - showDivider = true, - ) { - TangemRowContainer(modifier = Modifier.padding(top = 20.dp, bottom = 24.dp)) { - Text( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), - text = "${state.score}", - color = TangemTheme.colors2.text.neutral.primary, - style = TangemTheme.typography2.headingBold28, - ) + title = { + TangemRowContainer(contentPadding = PaddingValues()) { + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = "${state.score}", + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.headingSemibold20, + ) - InformationTextBlock( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), - text = resourceReference(R.string.markets_token_details_security_score), - onInfoClick = state.onInfoClick, - textColor = TangemTheme.colors2.text.neutral.primary, - informationTextBlockIconPosition = InformationTextBlockIconPosition.END, - ) + InformationTextBlock( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), + text = resourceReference(R.string.markets_token_details_security_score), + onInfoClick = state.onInfoClick, + informationTextBlockIconPosition = InformationTextBlockIconPosition.START, + ) - Text( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), - text = state.description.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), + text = state.description.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - ScoreStarsBlock( - modifier = Modifier - .padding(bottom = 16.dp) - .layoutId(layoutId = TangemRowLayoutId.END_TOP), - score = state.score, - scoreTextStyle = TangemTheme.typography.body1, - horizontalSpacing = TangemTheme.dimens.spacing8, - ) - } - } + ScoreStarsBlock( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + score = state.score, + scoreTextStyle = TangemTheme.typography.body1, + horizontalSpacing = TangemTheme.dimens.spacing8, + ) + } + }, + ) } @Composable @@ -146,7 +143,7 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { .width(74.dp) .padding(top = 8.dp) .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, radius = TangemTheme.dimens2.x25, ) @@ -163,7 +160,7 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { .width(96.dp) .padding(top = 8.dp) .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, radius = TangemTheme.dimens2.x25, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index de92450ef8..20520a4398 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.items.DescriptionPlaceholder +import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.feed.components.NewsSlider @@ -76,6 +78,8 @@ private fun LazyListScope.tokenMarketDetailsBodyV1( if (relatedNews.articles.isNotEmpty()) { relatedNews(relatedNews) + } else { + sectionStub(RelatedNews.SECTION_KEY) } aboutCoinHeader() @@ -91,6 +95,11 @@ private fun LazyListScope.tokenMarketDetailsBodyV1( } } +// Empty item with a key so that deeplink scroll-to-section can target it before the real content is composed +private fun LazyListScope.sectionStub(key: String) { + item(key) { } +} + @Suppress("CanBeNonNullable") private fun LazyListScope.tokenMarketDetailsBodyV2(state: MarketsTokenDetailsUM.Body, relatedNews: RelatedNews) { when (state) { @@ -154,7 +163,18 @@ private fun LazyListScope.aboutCoinHeader() { private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) { item("description") { DescriptionItem( - modifier = Modifier.blockPaddings(), + modifier = Modifier + .conditionalCompose( + condition = LocalRedesignEnabled.current, + modifier = { + this + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x8) + }, + otherModifier = { + blockPaddings() + }, + ), description = description.shortDescription, hasFullDescription = description.fullDescription != null, onReadMoreClick = description.onReadMoreClick, @@ -242,10 +262,6 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa ) } - if (relatedNews.articles.isNotEmpty()) { - relatedNews(relatedNews) - } - if (state.securityScore != null) { item("securityScore") { SecurityScoreBlock( @@ -255,6 +271,12 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa } } + if (relatedNews.articles.isNotEmpty()) { + relatedNewsV2(relatedNews) + } else { + sectionStub(RelatedNews.SECTION_KEY) + } + if (state.links != null) { item("links") { LinksBlock( @@ -324,7 +346,7 @@ private fun LazyListScope.loadingInfoBlocksV2() { } private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { - item("related-news") { + item(RelatedNews.SECTION_KEY) { Column( modifier = Modifier .fillMaxWidth() @@ -358,11 +380,52 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { } } +private fun LazyListScope.relatedNewsV2(relatedNews: RelatedNews) { + item(RelatedNews.SECTION_KEY) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x8) + .onFirstVisible( + minFractionVisible = 0.5f, + callback = relatedNews.onFirstVisible, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3), + ) { + Text( + modifier = Modifier.padding(start = TangemTheme.dimens2.x6), + text = stringResourceSafe(R.string.news_related_news), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + + NewsSlider( + NewsSliderConfig( + callbacks = NewsSliderCallbacks( + onOpenAllNews = {}, // not applicable here + onSliderScroll = relatedNews.onScroll, + onSliderEndReached = {}, // not applicable here + onArticleClick = relatedNews.onArticledClicked, + ), + content = relatedNews.articles, + shouldShowSeeAllNewsItem = false, + ), + ) + } + } +} + @Composable private fun Modifier.blockPaddings(): Modifier { - return this.padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing12, - ) + return if (LocalRedesignEnabled.current) { + this + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x2) + } else { + this.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing12, + ) + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt index 2cd7078d51..cb53a7dd7e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -5,6 +5,7 @@ import com.tangem.common.ui.charts.state.MarketChartDataProducer import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.markets.PriceChangeInterval import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM @@ -30,6 +31,7 @@ internal data class MarketsTokenDetailsUM( val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, val relatedNews: RelatedNews, val onShareClick: () -> Unit, + val scrollToSection: StateEvent = consumedEvent(), ) { data class ChartState( @@ -80,5 +82,9 @@ internal data class MarketsTokenDetailsUM( val onArticledClicked: (id: Int) -> Unit, val onFirstVisible: () -> Unit, val onScroll: () -> Unit, - ) + ) { + companion object { + const val SECTION_KEY = "related-news" + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index cafab387b7..fb12e37421 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -8,13 +8,17 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider import com.tangem.core.ui.components.* @@ -26,9 +30,9 @@ import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.event.consumedEvent -import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme @@ -104,33 +108,43 @@ internal fun TopBarWithSearch( @Composable internal fun MarketsList(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - Column( - modifier = modifier - .fillMaxSize() - .imePadding() - .drawBehind { drawRect(background) }, - ) { - Content(state = state, contentPadding = contentPadding) + // should use here new overrided haze state cause on level upper already applied + CompositionLocalProvider(LocalHazeState provides rememberHazeState()) { + Column( + modifier = modifier + .fillMaxSize() + .imePadding() + .drawBehind { drawRect(background) }, + ) { + Content(state = state, contentPadding = contentPadding) + } + MarketsListSortByBottomSheet(config = state.sortByBottomSheet) + KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown) } - MarketsListSortByBottomSheet(config = state.sortByBottomSheet) - KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown) } @Suppress("LongMethod") @Composable private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) { - val isRedesignEnabled = LocalRedesignEnabled.current - - val hazeState = rememberHazeState() - - val strokeColor = if (isRedesignEnabled) { - TangemTheme.colors2.border.neutral.primary + if (LocalRedesignEnabled.current) { + ContentV2( + contentPadding = contentPadding, + state = state, + ) } else { - TangemTheme.colors.stroke.primary + ContentV1( + contentPadding = contentPadding, + state = state, + modifier = modifier, + ) } +} +@Suppress("LongMethod") +@Composable +private fun ColumnScope.ContentV1(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) { + val strokeColor = TangemTheme.colors.stroke.primary val scrolledState = remember { mutableStateOf(false) } - Column(modifier.padding(horizontal = TangemTheme.dimens.size16)) { SpacerH(contentPadding.calculateTopPadding()) AnimatedVisibility( @@ -151,19 +165,12 @@ private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsLis Column { AnimatedVisibility(!state.isInSearchMode && !state.marketsSearchBar.shouldAlwaysShowSearchBar) { Options( - modifier = Modifier.padding( - bottom = if (isRedesignEnabled) { - TangemTheme.dimens2.x2 - } else { - TangemTheme.dimens.spacing12 - }, - ), + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), sortByTypeUM = state.selectedSortBy, trendInterval = state.selectedInterval, onIntervalClick = state.onIntervalClick, onSortByClick = state.onSortByButtonClick, sortMenuUM = state.sortByMenuUM, - hazeState = hazeState, ) } } @@ -186,24 +193,57 @@ private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsLis }, ) ItemsList( - modifier = Modifier.conditionalCompose( - condition = isRedesignEnabled, - modifier = { - hazeSourceTangem(zIndex = 0f, state = hazeState) - }, - ), scrolledState = scrolledState, isInSearchMode = state.isInSearchMode, state = state.list, ) } +@Suppress("LongMethod") +@Composable +private fun ColumnScope.ContentV2(contentPadding: PaddingValues, state: MarketsListUM) { + val scrolledState = remember { mutableStateOf(false) } + var optionsHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box(modifier = Modifier.fillMaxSize()) { + ItemsList( + topContentPadding = contentPadding.calculateTopPadding() + optionsHeight, + modifier = Modifier + .align(Alignment.TopStart) + .hazeSourceTangem(zIndex = 1f), + scrolledState = scrolledState, + isInSearchMode = state.isInSearchMode, + state = state.list, + ) + Options( + modifier = Modifier + .align(Alignment.TopStart) + .padding(bottom = TangemTheme.dimens2.x4, top = contentPadding.calculateTopPadding()) + .padding(horizontal = TangemTheme.dimens2.x4) + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + optionsHeight = coordinates.size.height.toDp() + } + } + }, + sortByTypeUM = state.selectedSortBy, + trendInterval = state.selectedInterval, + onIntervalClick = state.onIntervalClick, + onSortByClick = state.onSortByButtonClick, + sortMenuUM = state.sortByMenuUM, + ) + } +} + @Composable private fun ItemsList( scrolledState: MutableState, isInSearchMode: Boolean, state: ListUM, modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { val searchLazyListState = rememberLazyListState() val mainLazyListState = rememberLazyListState() @@ -229,6 +269,7 @@ private fun ItemsList( } MarketsListLazyColumn( + topContentPadding = topContentPadding, modifier = modifier, state = state, isInSearchMode = isInSearchMode, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt index 3f7a0fccf5..13eff7a17e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder @@ -43,6 +44,7 @@ internal fun MarketsListLazyColumn( isInSearchMode: Boolean, lazyListState: LazyListState, modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { val isRedesignEnabled = LocalRedesignEnabled.current val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } @@ -66,7 +68,7 @@ internal fun MarketsListLazyColumn( LazyColumn( modifier = modifier, state = rememberLazyListState(), - contentPadding = PaddingValues(bottom = bottomBarHeight), + contentPadding = PaddingValues(bottom = bottomBarHeight, top = topContentPadding), userScrollEnabled = false, ) { items(count = 100, key = { it }) { @@ -77,7 +79,7 @@ internal fun MarketsListLazyColumn( LazyColumn( modifier = modifier.testTag(MarketsTestTags.TOKENS_LIST), state = lazyListState, - contentPadding = PaddingValues(bottom = bottomBarHeight), + contentPadding = PaddingValues(bottom = bottomBarHeight, top = topContentPadding), userScrollEnabled = true, ) { // ATTENTION! There should be no elements with a string key value except MarketsListItem! diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt index 5dc049b114..191c6db56d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt @@ -1,5 +1,6 @@ package com.tangem.features.feed.ui.market.list.components +import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.* @@ -19,13 +20,14 @@ import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.tabs.TangemSegmentUM import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByMenuUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM -import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeProgressive import kotlinx.collections.immutable.persistentListOf import com.tangem.core.ui.ds.button.TangemButtonIconPosition as RedesignTangemButtonIconPosition @@ -35,7 +37,6 @@ internal fun Options( sortMenuUM: SortByMenuUM, sortByTypeUM: SortByTypeUM, trendInterval: MarketsListUM.TrendInterval, - hazeState: HazeState, onSortByClick: () -> Unit, onIntervalClick: (MarketsListUM.TrendInterval) -> Unit, modifier: Modifier = Modifier, @@ -46,7 +47,6 @@ internal fun Options( trendInterval = trendInterval, onIntervalClick = onIntervalClick, modifier = modifier, - hazeState = hazeState, ) } else { OptionsV1( @@ -116,11 +116,11 @@ private fun OptionsV1( private fun OptionsV2( sortMenuUM: SortByMenuUM, trendInterval: MarketsListUM.TrendInterval, - hazeState: HazeState, onIntervalClick: (MarketsListUM.TrendInterval) -> Unit, modifier: Modifier = Modifier, ) { var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) } + val background = LocalMainBottomSheetColor.current.value val segmentItems = remember { persistentListOf( @@ -139,42 +139,55 @@ private fun OptionsV2( ) } - Row( + Box( modifier = modifier - .height(IntrinsicSize.Max) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + .fillMaxWidth() + .wrapContentHeight(align = Alignment.Top), ) { - PrimaryInverseTangemButton( - onClick = { - isShowDropdownMenu = true - }, - iconPosition = RedesignTangemButtonIconPosition.End, - tangemIconUM = TangemIconUM.Icon( - iconRes = R.drawable.ic_chewron_down_20, - tintReference = { TangemTheme.colors2.graphic.neutral.primary }, - ), - text = sortMenuUM.selectedOption.text, - size = TangemButtonSize.X9, - shape = TangemButtonShape.Rounded, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .hazeEffectTangem { + progressive = HazeProgressive.verticalGradient( + startIntensity = .2f, + endIntensity = 0f, + easing = EaseOut, + preferPerformance = true, + ) + backgroundColor = background + }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PrimaryInverseTangemButton( + onClick = { isShowDropdownMenu = true }, + iconPosition = RedesignTangemButtonIconPosition.End, + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_chewron_down_20, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + text = sortMenuUM.selectedOption.text, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + ) - TangemSegmentedPicker( - items = segmentItems, - initialSelectedItem = segmentItems.firstOrNull { it.id == trendInterval.name }, - isFixed = false, - isAltSurface = true, - minSegmentWidth = 54.dp, - onClick = { segment -> onIntervalClick(MarketsListUM.TrendInterval.valueOf(segment.id)) }, + TangemSegmentedPicker( + items = segmentItems, + initialSelectedItem = segmentItems.firstOrNull { it.id == trendInterval.name }, + isFixed = false, + isAltSurface = true, + minSegmentWidth = 54.dp, + onClick = { segment -> onIntervalClick(MarketsListUM.TrendInterval.valueOf(segment.id)) }, + ) + } + + SortByMenu( + sortMenuUM = sortMenuUM, + showDropdownMenu = isShowDropdownMenu, + onDropdownDismiss = { isShowDropdownMenu = false }, + modifier = Modifier + .align(Alignment.TopStart) + .hazeEffectTangem { blurRadius = 10.dp }, ) } - - SortByMenu( - sortMenuUM = sortMenuUM, - showDropdownMenu = isShowDropdownMenu, - onDropdownDismiss = { isShowDropdownMenu = false }, - modifier = Modifier.hazeEffectTangem(hazeState) { - blurRadius = 10.dp - }, - ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index 9b7905ade3..6e71c83a26 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -22,6 +22,9 @@ import com.tangem.features.feed.ui.news.details.components.NewsDetailsPlaceholde import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import dev.chrisbanes.haze.rememberHazeState + +private const val PAGER_BG_ALPHA = .1f @Composable internal fun NewsDetailsContent(state: NewsDetailsUM, contentPadding: PaddingValues, modifier: Modifier = Modifier) { @@ -64,6 +67,7 @@ private fun Content(contentPadding: PaddingValues, state: NewsDetailsUM, backgro initialPage = state.selectedArticleIndex, pageCount = { state.articles.size }, ) + val localHaze = rememberHazeState() if (state.articles.isNotEmpty()) { LaunchedEffect(pagerState) { @@ -98,6 +102,7 @@ private fun Content(contentPadding: PaddingValues, state: NewsDetailsUM, backgro onLikeClick = { state.onLikeClick(article.id) }, relatedTokensUM = state.relatedTokensUM, contentPadding = contentPadding, + hazeState = localHaze, ) } if (state.articles.size > 1) { @@ -108,8 +113,9 @@ private fun Content(contentPadding: PaddingValues, state: NewsDetailsUM, backgro .align(Alignment.BottomCenter) .windowInsetsPadding(WindowInsets.navigationBars), colors = TangemPagerIndicatorColors.copy( - overlay = TangemTheme.colors2.tabs.backgroundSecondary.copy(alpha = .1f), + overlay = TangemTheme.colors2.tabs.backgroundSecondary.copy(PAGER_BG_ALPHA), ), + hazeState = localHaze, ) } else { PagerIndicator( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt index 4b9ac978b3..d7fdd670cf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -19,7 +18,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.BottomFade -import com.tangem.core.ui.components.BottomFadeWithBlur import com.tangem.core.ui.components.SecondaryButtonIconStart import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -38,10 +36,11 @@ import com.tangem.features.feed.ui.feed.components.articles.ArticleHeader import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM -import dev.chrisbanes.haze.rememberHazeState +import dev.chrisbanes.haze.HazeState @Composable internal fun ArticleDetail( + hazeState: HazeState, contentPadding: PaddingValues, article: ArticleUM, onLikeClick: () -> Unit, @@ -55,6 +54,7 @@ internal fun ArticleDetail( onLikeClick = onLikeClick, relatedTokensUM = relatedTokensUM, modifier = modifier, + hazeState = hazeState, ) } else { ArticleDetailV1( @@ -195,7 +195,8 @@ private fun ArticleDetailV1( @Suppress("LongMethod") @Composable -internal fun ArticleDetailV2( +private fun ArticleDetailV2( + hazeState: HazeState, contentPadding: PaddingValues, article: ArticleUM, onLikeClick: () -> Unit, @@ -206,16 +207,16 @@ internal fun ArticleDetailV2( val density = LocalDensity.current val background = LocalMainBottomSheetColor.current.value val pagerHeight = 32.dp - val bottomPadding = pagerHeight + 56.dp + with(density) { + val bottomPadding = pagerHeight + TangemTheme.dimens2.x4 + with(density) { WindowInsets.navigationBars.getBottom(this).div(this.density) }.dp - CompositionLocalProvider(LocalHazeState provides rememberHazeState()) { + CompositionLocalProvider(LocalHazeState provides hazeState) { Box(modifier = modifier) { LazyColumn( modifier = Modifier .fillMaxSize() - .hazeSourceTangem(zIndex = -1f) + .hazeSourceTangem(hazeState) .background(background), contentPadding = PaddingValues(bottom = bottomPadding, top = contentPadding.calculateTopPadding()), ) { @@ -227,39 +228,27 @@ internal fun ArticleDetailV2( tags = article.tags, isTrending = article.isTrending, modifier = Modifier - .padding(top = 16.dp) - .padding(horizontal = 16.dp), + .padding(top = TangemTheme.dimens2.x1_5, bottom = TangemTheme.dimens2.x6) + .padding(horizontal = TangemTheme.dimens2.x4), ) if (article.shortContent.isNotEmpty()) { QuickRecap( content = article.shortContent, - modifier = Modifier - .padding(top = 32.dp) - .padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), ) } Text( text = article.content, - style = TangemTheme.typography2.bodyRegular16, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.primary, modifier = Modifier - .padding(top = 12.dp) - .padding(horizontal = 16.dp), + .padding(vertical = TangemTheme.dimens2.x5, horizontal = TangemTheme.dimens2.x6), ) - SpacerH(24.dp) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = 24.dp), - color = TangemTheme.colors2.border.neutral.primary, - ) - - SpacerH(20.dp) - SecondaryTangemButton( - modifier = Modifier.padding(horizontal = 24.dp), + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x6), text = resourceReference(R.string.news_like), size = com.tangem.core.ui.ds.button.TangemButtonSize.X9, tangemIconUM = if (article.isLiked) { @@ -286,31 +275,27 @@ internal fun ArticleDetailV2( is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick else -> null }, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), ) if (article.relatedArticles.isNotEmpty()) { - SpacerH(24.dp) - Row( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResourceSafe(R.string.news_sources), - style = TangemTheme.typography2.headingSemibold20, - color = TangemTheme.colors2.text.neutral.primary, - ) - } + SpacerH(TangemTheme.dimens2.x4) + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x6), + text = stringResourceSafe(R.string.news_sources), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) } } if (article.relatedArticles.isNotEmpty()) { item("relatedArticles") { LazyRow( - modifier = Modifier.padding(vertical = 12.dp), + modifier = Modifier.padding(vertical = TangemTheme.dimens2.x3), state = rememberLazyListState(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = TangemTheme.dimens2.x4), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3), ) { items( items = article.relatedArticles, @@ -326,12 +311,12 @@ internal fun ArticleDetailV2( } } - BottomFadeWithBlur( + BottomFade( modifier = Modifier .align(Alignment.BottomCenter) - .height(80.dp) .fillMaxWidth(), backgroundColor = background, + height = TangemTheme.dimens2.x15, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt index dc9107030f..e102f1591a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt @@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.news.details.components import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -111,38 +110,12 @@ private fun NewsDetailsPlaceholderV2(contentPadding: PaddingValues, background: .padding(16.dp), ) { SpacerH(contentPadding.calculateTopPadding()) - Row( - modifier = Modifier.height(50.dp), - horizontalArrangement = Arrangement.spacedBy(30.dp), - ) { - Column { - RectangleShimmer( - modifier = Modifier.size(width = 50.dp, height = 20.dp), - radius = TangemTheme.dimens2.x25, - ) - SpacerH(10.dp) - RectangleShimmer( - modifier = Modifier.size(width = 90.dp, height = 18.dp), - radius = TangemTheme.dimens2.x25, - ) - } + RectangleShimmer( + modifier = Modifier.size(width = 90.dp, height = 20.dp), + radius = TangemTheme.dimens2.x25, + ) - VerticalDivider(color = TangemTheme.colors2.border.neutral.primary) - - Column { - RectangleShimmer( - modifier = Modifier.size(width = 50.dp, height = 20.dp), - radius = TangemTheme.dimens2.x25, - ) - SpacerH(10.dp) - RectangleShimmer( - modifier = Modifier.size(width = 90.dp, height = 18.dp), - radius = TangemTheme.dimens2.x25, - ) - } - } - - SpacerH(36.dp) + SpacerH(16.dp) RectangleShimmer( modifier = Modifier @@ -161,7 +134,7 @@ private fun NewsDetailsPlaceholderV2(contentPadding: PaddingValues, background: radius = TangemTheme.dimens2.x25, ) - SpacerH(36.dp) + SpacerH(30.dp) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { RectangleShimmer( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt index 6cc0ec1434..5730440a49 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt @@ -82,7 +82,7 @@ private fun QuickRecapV2(content: String, modifier: Modifier = Modifier) { contentDescription = null, ) - SpacerW(2.dp) + SpacerW(TangemTheme.dimens2.x1) Text( text = buildAnnotatedString { @@ -97,26 +97,26 @@ private fun QuickRecapV2(content: String, modifier: Modifier = Modifier) { append(stringResourceSafe(R.string.news_quick_recap)) } }, - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } - SpacerH(10.dp) + SpacerH(TangemTheme.dimens2.x2_5) Box { VerticalDivider( modifier = Modifier .fillMaxHeight() - .padding(start = 10.dp), - thickness = 2.dp, + .padding(start = TangemTheme.dimens2.x2_5), + thickness = TangemTheme.dimens2.x0_5, color = Color(QUICK_RECAP_DIVIDER_COLOR), ) Text( - modifier = Modifier.padding(start = 20.dp), + modifier = Modifier.padding(start = TangemTheme.dimens2.x5), text = content, - style = TangemTheme.typography2.bodyRegular16, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.primary, ) } @@ -138,7 +138,7 @@ private fun QuickRecapPreview() { } private const val QUICK_RECAP_DIVIDER_COLOR = 0xFFA99FFF -private const val LINEAR_GRADIENT_FIRST_PART = 0xFFA3A0FF -private const val LINEAR_GRADIENT_SECOND_PART = 0xFFF79DFF +private const val LINEAR_GRADIENT_FIRST_PART = 0xFF7B78FF +private const val LINEAR_GRADIENT_SECOND_PART = 0xFFC56BCD private const val GRADIENT_START = 0f private const val GRADIENT_END = 0.5f \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt index a3810b1bda..f99d0eff17 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt @@ -1,7 +1,6 @@ package com.tangem.features.feed.ui.news.details.components import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -113,42 +112,37 @@ private fun RelatedNewsItemV1(relatedArticle: RelatedArticleUM, modifier: Modifi private fun RelatedNewsItemV2(relatedArticle: RelatedArticleUM, modifier: Modifier = Modifier) { Column( modifier = modifier - .sizeIn(maxWidth = 228.dp, minHeight = 160.dp) + .sizeIn(maxWidth = 280.dp, minHeight = 164.dp) .background( color = TangemTheme.colors2.surface.level3, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), + shape = RoundedCornerShape(TangemTheme.dimens2.x6), ) .clickable(onClick = relatedArticle.onClick) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), - ) - .padding(16.dp), + .padding(TangemTheme.dimens2.x4), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x4)) { Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource(id = R.drawable.ic_explore_16), contentDescription = null, tint = TangemTheme.colors2.markers.iconGray, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(TangemTheme.dimens2.x4), ) - SpacerW(2.dp) + SpacerW(TangemTheme.dimens2.x0_5) Text( text = relatedArticle.media.name, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, overflow = TextOverflow.Ellipsis, color = TangemTheme.colors2.text.neutral.secondary, ) } if (relatedArticle.title.isNotEmpty()) { - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x2) Text( text = relatedArticle.title, - style = TangemTheme.typography2.bodyRegular16, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.primary, maxLines = 3, overflow = TextOverflow.Ellipsis, @@ -181,7 +175,7 @@ private fun RelatedNewsItemV2(relatedArticle: RelatedArticleUM, modifier: Modifi SpacerHMax() Text( text = relatedArticle.publishedAt.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.secondary, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt index 7f37764ee5..8013858af1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt @@ -1,8 +1,11 @@ package com.tangem.features.feed.ui.news.details.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -28,7 +31,27 @@ internal fun RelatedTokensBlock( onItemClick: ((MarketsListItemUM) -> Unit)?, modifier: Modifier = Modifier, ) { - val isRedesignEnabled = LocalRedesignEnabled.current + if (LocalRedesignEnabled.current) { + RelatedTokensBlockV2( + relatedTokensUM = relatedTokensUM, + onItemClick = onItemClick, + modifier = modifier, + ) + } else { + RelatedTokensBlockV1( + relatedTokensUM = relatedTokensUM, + onItemClick = onItemClick, + modifier = modifier, + ) + } +} + +@Composable +internal fun RelatedTokensBlockV1( + relatedTokensUM: RelatedTokensUM, + onItemClick: ((MarketsListItemUM) -> Unit)?, + modifier: Modifier = Modifier, +) { val isVisible = remember(relatedTokensUM) { when (relatedTokensUM) { is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty() @@ -41,30 +64,15 @@ internal fun RelatedTokensBlock( Column(modifier = modifier) { SpacerH(40.dp) - if (isRedesignEnabled) { - Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = stringResourceSafe(R.string.news_related_tokens), - style = TangemTheme.typography2.headingSemibold20, - color = TangemTheme.colors2.text.neutral.primary, - ) - } else { - Text( - text = stringResourceSafe(R.string.news_related_tokens), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) - } + Text( + text = stringResourceSafe(R.string.news_related_tokens), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) SpacerH(12.dp) BlockCard( - colors = TangemBlockCardColors.copy( - containerColor = if (isRedesignEnabled) { - TangemTheme.colors2.surface.level3 - } else { - TangemTheme.colors.background.action - }, - ), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) { Column(modifier = Modifier.fillMaxWidth()) { when (relatedTokensUM) { @@ -86,4 +94,72 @@ internal fun RelatedTokensBlock( } } } +} + +@Composable +internal fun RelatedTokensBlockV2( + relatedTokensUM: RelatedTokensUM, + onItemClick: ((MarketsListItemUM) -> Unit)?, + modifier: Modifier = Modifier, +) { + val isVisible = remember(relatedTokensUM) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty() + RelatedTokensUM.Loading -> true + RelatedTokensUM.LoadingError -> false + } + } + + if (!isVisible) return + + Column(modifier = modifier) { + SpacerH(TangemTheme.dimens2.x6) + Text( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens2.x2) + .padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2), + text = stringResourceSafe(R.string.news_related_tokens), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x3) + + Column(modifier = Modifier.fillMaxWidth()) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> { + relatedTokensUM.items.fastForEach { marketsListItemUM -> + WithDecorated { + MarketsListItem( + model = marketsListItemUM, + onClick = { onItemClick?.invoke(marketsListItemUM) }, + ) + } + SpacerH(TangemTheme.dimens2.x2) + } + } + RelatedTokensUM.Loading -> { + repeat(RELATED_TOKEN_MAX_COUNT) { + WithDecorated { + MarketsListItemPlaceholder() + } + SpacerH(TangemTheme.dimens2.x2) + } + } + RelatedTokensUM.LoadingError -> Unit + } + } + } +} + +@Composable +private fun WithDecorated(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + content = content, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt index 933d5fe2a9..9bc099b815 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt @@ -1,38 +1,60 @@ package com.tangem.features.feed.ui.news.list +import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity 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.chip.Chip import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.ds.tabs.TangemTab +import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.core.ui.res.LocalRedesignEnabled -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.* import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.features.feed.ui.news.list.components.NewsListLazyColumn import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM +import dev.chrisbanes.haze.HazeProgressive import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet @Composable internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + NewsListContentV2( + contentPadding = contentPadding, + state = state, + ) + } else { + NewsListContentV1( + contentPadding = contentPadding, + state = state, + modifier = modifier, + ) + } +} + +@Composable +internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - val isRedesignEnabled = LocalRedesignEnabled.current val lazyListState = rememberLazyListState() + val chipsListState = rememberLazyListState() + + ScrollChipsToSelected(state = state, chipsListState = chipsListState) Column( modifier = modifier @@ -41,6 +63,7 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m ) { SpacerH(contentPadding.calculateTopPadding()) LazyRow( + state = chipsListState, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -48,17 +71,7 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m items = state.filters, key = { it.id }, ) { filter -> - if (isRedesignEnabled) { - TangemTab( - text = filter.text, - isChecked = filter.isSelected, - onCheckedChange = { - filter.onClick() - }, - ) - } else { - Chip(state = filter) - } + Chip(state = filter) } } @@ -73,6 +86,78 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m } } +@Composable +internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) { + val background = LocalMainBottomSheetColor.current.value + val lazyListState = rememberLazyListState() + val chipsListState = rememberLazyListState() + var chipsHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + ScrollChipsToSelected(state = state, chipsListState = chipsListState) + + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + NewsListLazyColumn( + topContentPadding = contentPadding.calculateTopPadding() + 16.dp + chipsHeight, + modifier = Modifier + .hazeSourceTangem(zIndex = 0f) + .align(Alignment.TopStart), + newsListState = state.newsListState, + listOfArticles = state.listOfArticles, + lazyListState = lazyListState, + onArticleClick = state.onArticleClick, + ) + LazyRow( + state = chipsListState, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = contentPadding.calculateTopPadding(), bottom = TangemTheme.dimens2.x4) + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + chipsHeight = coordinates.size.height.toDp() + } + } + } + .hazeEffectTangem { + progressive = HazeProgressive.verticalGradient( + startIntensity = .2f, + endIntensity = 0f, + easing = EaseOut, + preferPerformance = true, + ) + backgroundColor = background + }, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.filters, + key = { it.id }, + ) { filter -> + TangemTab( + text = filter.text, + isChecked = filter.isSelected, + onCheckedChange = { + filter.onClick() + }, + ) + } + } + } +} + +@Composable +private fun ScrollChipsToSelected(state: NewsListUM, chipsListState: LazyListState) { + EventEffect(event = state.scrollToCategoryEvent) { index -> + chipsListState.animateScrollToItem(index) + } +} + @Suppress("LongMethod") @Preview(showBackground = true) @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt index 177f191773..d9262be19c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt @@ -15,15 +15,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tangem.features.feed.ui.feed.components.articles.ArticleCard -import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM -import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.feed.components.articles.ArticleCard +import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM +import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle import com.tangem.features.feed.ui.news.list.state.NewsListState import kotlinx.collections.immutable.ImmutableList @@ -35,6 +36,8 @@ internal fun NewsListLazyColumn( newsListState: NewsListState, lazyListState: LazyListState, onArticleClick: (Int) -> Unit, + modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { val screenState by remember(listOfArticles, newsListState) { derivedStateOf { @@ -48,6 +51,7 @@ internal fun NewsListLazyColumn( } AnimatedContent( + modifier = modifier, transitionSpec = { fadeIn() togetherWith fadeOut() }, @@ -57,6 +61,7 @@ internal fun NewsListLazyColumn( when (state) { NewsListScreenState.Content -> { Content( + topContentPadding = topContentPadding, listOfArticles = listOfArticles, newsListState = newsListState, lazyListState = lazyListState, @@ -66,7 +71,7 @@ internal fun NewsListLazyColumn( NewsListScreenState.InitialLoading -> { LazyColumn( state = rememberLazyListState(), - contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp, top = topContentPadding), userScrollEnabled = false, ) { items( @@ -100,11 +105,12 @@ private fun Content( lazyListState: LazyListState, onArticleClick: (Int) -> Unit, modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { LazyColumn( modifier = modifier, state = lazyListState, - contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp, top = topContentPadding), userScrollEnabled = true, ) { items( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt index 9d8c0e32b1..b910d4f30d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt @@ -1,8 +1,10 @@ package com.tangem.features.feed.ui.news.list.state import androidx.compose.runtime.Immutable -import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.event.consumedEvent +import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import kotlinx.collections.immutable.ImmutableList @Immutable @@ -13,6 +15,7 @@ data class NewsListUM( val newsListState: NewsListState, val onArticleClick: (Int) -> Unit, val onBackClick: () -> Unit, + val scrollToCategoryEvent: StateEvent = consumedEvent(), ) @Immutable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 287b580b15..41bed10ea9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -1,22 +1,18 @@ package com.tangem.features.feed.ui.search +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable 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.vector.ImageVector import androidx.compose.ui.platform.LocalDensity @@ -26,12 +22,14 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.common.ui.markets.tokenselector.GroupedUserAssetItem +import com.tangem.common.ui.markets.tokenselector.SingleUserAssetItem +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.ds.button.* -import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.resourceReference @@ -39,9 +37,11 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.ui.search.state.* +import kotlinx.collections.immutable.ImmutableList private const val PLACEHOLDER_COUNT = 10 private const val LOAD_MORE_THRESHOLD = 5 +private const val USER_ASSETS_LIMIT = 3 @Composable internal fun SearchContent( @@ -54,6 +54,23 @@ internal fun SearchContent( val lazyListState = rememberLazyListState() val background = LocalMainBottomSheetColor.current.value + val contentStructureKey = when (content) { + is SearchContentUM.InitialEmpty -> "empty" + is SearchContentUM.History -> "history" + is SearchContentUM.Results -> "results_${content.userAssets.isNotEmpty()}" + } + LaunchedEffect(contentStructureKey) { + lazyListState.scrollToItem(0) + } + + var isUserAssetsExpanded by rememberSaveable { mutableStateOf(false) } + val shouldShowUserAssetsPortfolio = content is SearchContentUM.Results && content.userAssets.isNotEmpty() + LaunchedEffect(shouldShowUserAssetsPortfolio) { + if (!shouldShowUserAssetsPortfolio) { + isUserAssetsExpanded = false + } + } + LazyColumn( state = lazyListState, modifier = modifier @@ -72,9 +89,12 @@ internal fun SearchContent( history = content, onClearAllClick = searchCallbacks.onClearHintsClick, onHintClick = searchCallbacks.onTextHintClick, + onHistoryTokenClick = searchCallbacks.onHistoryTokenClick, ) is SearchContentUM.Results -> searchResultsItems( results = content, + isUserAssetsExpanded = isUserAssetsExpanded, + onUserAssetsExpandedChange = { isUserAssetsExpanded = it }, onResultMarketTokenClick = searchCallbacks.onResultMarketTokenClick, ) } @@ -98,6 +118,7 @@ private fun LazyListScope.searchHistoryItems( history: SearchContentUM.History, onClearAllClick: (() -> Unit), onHintClick: (String) -> Unit, + onHistoryTokenClick: (MarketsListItemUM) -> Unit, ) { if (!history.textHints.isEmpty() || !history.recentTokens.isEmpty()) { item(key = "recents") { @@ -107,15 +128,20 @@ private fun LazyListScope.searchHistoryItems( ) } } - items( + itemsIndexed( items = history.textHints, - key = { "hint_${it.text}" }, - ) { hint -> + key = { _, item -> "hint_${item.text}" }, + ) { index, hint -> TextHintItem(hint = hint, onHintClick = { onHintClick(hint.text) }) - HorizontalDivider( - modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2), - color = TangemTheme.colors2.border.neutral.primary, - ) + if (index < history.textHints.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2), + color = TangemTheme.colors2.border.neutral.primary, + ) + } + } + item { + SpacerH(TangemTheme.dimens2.x2) } items( items = history.recentTokens, @@ -129,25 +155,26 @@ private fun LazyListScope.searchHistoryItems( shape = RoundedCornerShape(TangemTheme.dimens2.x5), ), model = token, - onClick = {}, // TODO in [REDACTED_TASK_KEY] + onClick = { onHistoryTokenClick(token) }, ) } } private fun LazyListScope.searchResultsItems( results: SearchContentUM.Results, + isUserAssetsExpanded: Boolean, + onUserAssetsExpandedChange: (Boolean) -> Unit, onResultMarketTokenClick: (MarketsListItemUM) -> Unit, ) { if (results.userAssets.isNotEmpty()) { item(key = "header_portfolio") { SectionHeader(title = stringResourceSafe(R.string.markets_search_portfolio_header)) } - items( - items = results.userAssets, - key = { it.id }, - ) { asset -> - UserAssetItem(asset) - } + userAssetsPortfolioItems( + assets = results.userAssets, + expanded = isUserAssetsExpanded, + onExpandedChange = onUserAssetsExpandedChange, + ) } when (val market = results.marketTokens) { @@ -164,6 +191,42 @@ private fun LazyListScope.searchResultsItems( } } +private fun LazyListScope.userAssetsPortfolioItems( + assets: ImmutableList, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, +) { + val shouldShowToggle = assets.size > USER_ASSETS_LIMIT + val visibleCount = if (shouldShowToggle && !expanded) USER_ASSETS_LIMIT else assets.size + + items( + count = visibleCount, + key = { index -> "user_asset_${assets[index].id}" }, + ) { index -> + Column( + modifier = Modifier.animateItem( + fadeInSpec = tween(durationMillis = 300), + fadeOutSpec = tween(durationMillis = 250), + ), + ) { + UserAssetItem(assets[index]) + SpacerH(TangemTheme.dimens2.x2) + } + } + if (shouldShowToggle) { + item(key = "user_assets_show_toggle") { + ShowAllUserAssetsButton( + modifier = Modifier.animateItem( + fadeInSpec = tween(durationMillis = 300), + fadeOutSpec = tween(durationMillis = 250), + ), + isExpanded = expanded, + onClick = { onExpandedChange(!expanded) }, + ) + } + } +} + private fun LazyListScope.marketSearchResultItems( market: MarketSearchResultUM.Content, hasUserAssetsSection: Boolean, @@ -223,7 +286,7 @@ private fun LazyListScope.marketSearchResultNotFoundItem() { ) { Text( text = stringResourceSafe(R.string.common_no_results), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.tertiary, ) } @@ -264,37 +327,44 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) { } } -// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. @Composable private fun UserAssetItem(asset: UserAssetItemUM) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = asset.onClick) - .padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TangemIcon( - tangemIconUM = TangemIconUM.Url(asset.tokenIconUrl, fallbackRes = R.drawable.ic_custom_token_44), - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = asset.tokenName, - style = TangemTheme.typography2.bodySemibold16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - Text( - text = "${asset.tokenSymbol} · ${asset.accountName}", - style = TangemTheme.typography2.captionRegular13, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - ) + when (asset) { + is UserAssetItemUM.Single -> Box( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + SingleUserAssetItem(item = asset, shouldUsePriceBlock = true) } + is UserAssetItemUM.Grouped -> GroupedUserAssetItem(item = asset) + } +} + +@Composable +private fun ShowAllUserAssetsButton(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + TangemButton( + buttonUM = TangemButtonUM( + text = if (isExpanded) { + resourceReference(R.string.feed_search_show_less_user_assets) + } else { + resourceReference(R.string.feed_search_show_all_user_assets) + }, + tangemIconUM = TangemIconUM.Icon( + iconRes = if (isExpanded) R.drawable.ic_chewron_up_20 else R.drawable.ic_chewron_down_20, + ), + iconPosition = TangemButtonIconPosition.End, + onClick = onClick, + type = TangemButtonType.Secondary, + size = TangemButtonSize.X7, + shape = TangemButtonShape.Rounded, + ), + ) } } @@ -314,7 +384,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod ) { Text( text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.secondary, ) TangemButton( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt index bfb95e3819..24cc5d32c7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt @@ -2,11 +2,7 @@ package com.tangem.features.feed.ui.search.preview import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -15,7 +11,12 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -206,19 +207,30 @@ internal object SearchContentPreviewFixtures { updateTimestamp = updateTimestamp, ) - private fun userAsset( - id: String, - name: String, - symbol: String, - accountName: String, - iconUrl: String? = null, - ): UserAssetItemUM = UserAssetItemUM( + private fun userAsset(id: String, name: String, symbol: String): UserAssetItemUM = UserAssetItemUM.Single( id = id, - tokenIconUrl = iconUrl, + icon = TangemIconUM.Currency( + CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), tokenName = name, tokenSymbol = symbol, - accountName = accountName, + fiatRate = "$98,765.43", + priceChangeState = PriceChangeState.Content( + type = PriceChangeType.UP, + valueInPercent = "+2.34%", + ), + balanceState = BalanceDisplayState.Loaded( + cryptoBalance = stringReference("1.234 $symbol"), + fiatBalance = stringReference("$121,876.50"), + ), + isBalanceHidden = false, onClick = {}, + networkName = "Ethereum", ) private fun textHint(text: String): TextHintItemUM = TextHintItemUM(text = text) @@ -251,13 +263,8 @@ internal object SearchContentPreviewFixtures { ) private fun portfolioTwo(): ImmutableList = persistentListOf( - userAsset(id = "p1", name = "Ethereum", symbol = "ETH", accountName = "Main wallet"), - userAsset( - id = "p2", - name = "Polygon", - symbol = "POL", - accountName = "Account with a long label for preview", - ), + userAsset(id = "p1", name = "Ethereum", symbol = "ETH"), + userAsset(id = "p2", name = "Polygon", symbol = "POL"), ) private fun marketListShort(): ImmutableList = persistentListOf( @@ -390,6 +397,7 @@ private val SearchContentPreviewCallbacks = SearchCallbacks( onClearHintsClick = {}, onTextHintClick = { _ -> }, onResultMarketTokenClick = { _ -> }, + onHistoryTokenClick = { _ -> }, ) /** All [SearchContentPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */ diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt new file mode 100644 index 0000000000..b2464602e1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt @@ -0,0 +1,245 @@ +package com.tangem.features.feed.ui.search.preview + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.GroupedUserAssetItem +import com.tangem.common.ui.markets.tokenselector.SingleUserAssetItem +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM + +/** Labeled UI state for [SingleUserAssetItem] previews (dropdown label in Studio). */ +internal data class SingleUserAssetItemPreviewScenario( + val title: String, + val item: UserAssetItemUM.Single, +) + +/** Labeled UI state for [GroupedUserAssetItem] previews. */ +internal data class GroupedUserAssetItemPreviewScenario( + val title: String, + val item: UserAssetItemUM.Grouped, +) + +@Suppress("StringLiteralDuplication") +internal object UserAssetItemPreviewFixtures { + + private val sampleIcon = TangemIconUM.Currency( + CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ) + + private val cryptoRef = stringReference("1.234 ETH") + private val fiatRef = stringReference("$121,876.50") + + private val samplePriceChange = PriceChangeState.Content( + valueInPercent = "+2.34%", + type = PriceChangeType.UP, + ) + + private fun balanceLoaded() = BalanceDisplayState.Loaded( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ) + + private fun balanceFlickering() = BalanceDisplayState.Flickering( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ) + + private fun balanceStale() = BalanceDisplayState.Stale( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ) + + private fun balanceLoading() = BalanceDisplayState.Loading + + private fun balanceUnreachable() = BalanceDisplayState.Unreachable + + fun allSingleScenarios(): List = listOf( + SingleUserAssetItemPreviewScenario( + title = "Hidden (balanceState ignored)", + item = single( + balanceState = balanceLoaded(), + isBalanceHidden = true, + ), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Loaded", + item = single(balanceState = balanceLoaded(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Flickering", + item = single(balanceState = balanceFlickering(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Stale", + item = single(balanceState = balanceStale(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Loading", + item = single(balanceState = balanceLoading(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Unreachable", + item = single(balanceState = balanceUnreachable(), isBalanceHidden = false), + ), + ) + + fun allGroupedScenarios(): List = listOf( + GroupedUserAssetItemPreviewScenario( + title = "Hidden (balanceState ignored)", + item = grouped( + balanceState = balanceLoaded(), + isBalanceHidden = true, + ), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Loaded", + item = grouped(balanceState = balanceLoaded(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Flickering", + item = grouped(balanceState = balanceFlickering(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Stale", + item = grouped(balanceState = balanceStale(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Loading", + item = grouped(balanceState = balanceLoading(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Unreachable", + item = grouped(balanceState = balanceUnreachable(), isBalanceHidden = false), + ), + ) + + private fun single(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Single = + UserAssetItemUM.Single( + id = "single_preview", + icon = sampleIcon, + tokenName = "Ethereum", + tokenSymbol = "ETH", + fiatRate = "$98,765.43", + priceChangeState = samplePriceChange, + balanceState = balanceState, + isBalanceHidden = isBalanceHidden, + onClick = {}, + networkName = "Ethereum", + ) + + private fun grouped(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Grouped = + UserAssetItemUM.Grouped( + id = "grouped_preview", + icon = sampleIcon, + tokenName = "Ethereum", + tokenSymbol = "ETH", + tokensCount = 3, + balanceState = balanceState, + isBalanceHidden = isBalanceHidden, + onClick = {}, + ) +} + +internal class SingleUserAssetItemPreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence + get() = UserAssetItemPreviewFixtures.allSingleScenarios().asSequence() +} + +internal class GroupedUserAssetItemPreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence + get() = UserAssetItemPreviewFixtures.allGroupedScenarios().asSequence() +} + +@Composable +private fun SingleUserAssetItemPreviewHost( + scenario: SingleUserAssetItemPreviewScenario, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + Box( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + SingleUserAssetItem(item = scenario.item, shouldUsePriceBlock = true) + } + } +} + +@Composable +private fun GroupedUserAssetItemPreviewHost( + scenario: GroupedUserAssetItemPreviewScenario, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + GroupedUserAssetItem(item = scenario.item) + } +} + +@Composable +@Preview(name = "Single – all balance states (parameter)", showBackground = true, widthDp = 360) +@Preview( + name = "Single – all balance states (night)", + showBackground = true, + widthDp = 360, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SingleUserAssetItemPreview_AllBalanceStates( + @PreviewParameter(SingleUserAssetItemPreviewParameterProvider::class) scenario: SingleUserAssetItemPreviewScenario, +) { + TangemThemePreviewRedesign { + SingleUserAssetItemPreviewHost(scenario = scenario) + } +} + +@Composable +@Preview(name = "Grouped – all balance states (parameter)", showBackground = true, widthDp = 360) +@Preview( + name = "Grouped – all balance states (night)", + showBackground = true, + widthDp = 360, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun GroupedUserAssetItemPreview_AllBalanceStates( + @PreviewParameter(GroupedUserAssetItemPreviewParameterProvider::class) + scenario: GroupedUserAssetItemPreviewScenario, +) { + TangemThemePreviewRedesign { + GroupedUserAssetItemPreviewHost(scenario = scenario) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt index e5641b2bb8..9535331da7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt @@ -7,4 +7,5 @@ internal data class SearchCallbacks( val onClearHintsClick: () -> Unit, val onTextHintClick: (hint: String) -> Unit, val onResultMarketTokenClick: (MarketsListItemUM) -> Unit, + val onHistoryTokenClick: (MarketsListItemUM) -> Unit, ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt index e4628ad30c..7dbf384023 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt @@ -2,6 +2,7 @@ package com.tangem.features.feed.ui.search.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.core.ui.components.fields.entity.SearchBarUM import kotlinx.collections.immutable.ImmutableList @@ -40,13 +41,4 @@ sealed interface MarketSearchResultUM { data object Empty : MarketSearchResultUM } -data class TextHintItemUM(val text: String) - -data class UserAssetItemUM( - val id: String, - val tokenIconUrl: String?, - val tokenName: String, - val tokenSymbol: String, - val accountName: String, - val onClick: () -> Unit, -) \ No newline at end of file +data class TextHintItemUM(val text: String) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt deleted file mode 100644 index ef92d71b3c..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.features.feed.ui.utils - -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.FormattedDate -import com.tangem.core.ui.utils.getFormattedDate -import com.tangem.features.feed.impl.R -import com.tangem.utils.StringsSigns -import org.joda.time.DateTime - -internal fun mapFormattedDate(createdAt: String): TextReference { - val formattedDate = runCatching { - getFormattedDate( - createdAt = createdAt, - now = DateTime.now(), - ) - }.getOrElse { FormattedDate.FullDate("") } - - return when (formattedDate) { - is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) - is FormattedDate.HoursAgo -> TextReference.PluralRes( - id = R.plurals.news_published_hours_ago, - count = formattedDate.hours, - formatArgs = wrappedList(formattedDate.hours), - ) - is FormattedDate.MinutesAgo -> TextReference.PluralRes( - id = R.plurals.news_published_minutes_ago, - count = formattedDate.minutes, - formatArgs = wrappedList(formattedDate.minutes), - ) - is FormattedDate.Today -> TextReference.Combined( - refs = WrappedList( - data = listOf( - TextReference.Res(R.string.common_today), - TextReference.Str(StringsSigns.COMA_SIGN), - TextReference.Str(StringsSigns.WHITE_SPACE), - TextReference.Str(formattedDate.time), - ), - ), - ) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt index 2bbfa496e2..5963f2ea34 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt @@ -27,11 +27,7 @@ internal fun contentFeedEntryStackAnimation(): StackAnimation< ComposableModularBottomSheetContentComponent, > = stackAnimation { to, from, _ -> - val isSearchToTokenList = - (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - val isFromSearchTokenList = - (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - if (isSearchToTokenList || isFromSearchTokenList) { + if (to.configuration.usesFadeStackTransition() || from.configuration.usesFadeStackTransition()) { fade() } else { slide() diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandlerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..d1113f142f --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandlerTest.kt @@ -0,0 +1,80 @@ +package com.tangem.features.feed.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.CATEGORY_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NEWS_ID_KEY +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DefaultNewsDeepLinkHandlerTest { + + private val appRouter: AppRouter = mockk() + + @BeforeEach + fun setUp() { + every { appRouter.push(any(), any()) } just Runs + } + + @Test + fun `no params opens news list with null category`() { + DefaultNewsDeepLinkHandler(queryParams = emptyMap(), appRouter = appRouter) + + verify { appRouter.push(AppRoute.News(categoryId = null), any()) } + } + + @Test + fun `valid categoryId opens news list with that category`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(CATEGORY_ID_KEY to "5"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.News(categoryId = 5), any()) } + } + + @Test + fun `unknown categoryId is forwarded — sanitized in NewsListModel against loaded chips`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(CATEGORY_ID_KEY to "42"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.News(categoryId = 42), any()) } + } + + @Test + fun `non-integer categoryId is silently dropped`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(CATEGORY_ID_KEY to "not-a-number"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.News(categoryId = null), any()) } + } + + @Test + fun `newsId opens news details`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(NEWS_ID_KEY to "20533"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.NewsDetails(newsId = 20533), any()) } + } + + @Test + fun `newsId takes priority over categoryId`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(NEWS_ID_KEY to "20533", CATEGORY_ID_KEY to "5"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.NewsDetails(newsId = 20533), any()) } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt new file mode 100644 index 0000000000..dd22b5353c --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt @@ -0,0 +1,304 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.google.common.truth.Truth +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM +import com.tangem.common.ui.markets.tokenselector.TokenSelectorSectionUM +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BuildTokenSelectorSectionsTransformerTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + private val onTokenSelected: (UserAssetEntry) -> Unit = mockk(relaxed = true) + private val prevState = TokenSelectorContentUM(sections = persistentListOf()) + + @BeforeEach + fun setup() { + clearMocks(onTokenSelected) + } + + @Test + fun `should return empty sections when entries list is empty`() { + val transformer = createTransformer(entries = emptyList()) + + val result = transformer.transform(prevState) + + Truth.assertThat(result.sections).isEmpty() + } + + @Test + fun `should create single TokenGroup without wallet header for single wallet`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + Truth.assertThat(result.sections).hasSize(1) + Truth.assertThat(result.sections[0]).isInstanceOf(TokenSelectorSectionUM.TokenGroup::class.java) + + val group = result.sections[0] as TokenSelectorSectionUM.TokenGroup + Truth.assertThat(group.items).hasSize(2) + Truth.assertThat(group.accountHeader).isNull() + } + + @Test + fun `should add wallet headers when multiple wallets present`() { + val walletId1 = createMockUserWalletId("wallet1") + val walletId2 = createMockUserWalletId("wallet2") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId1, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId2, "Wallet 2", accountId, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val walletHeaders = result.sections.filterIsInstance() + val tokenGroups = result.sections.filterIsInstance() + + Truth.assertThat(walletHeaders).hasSize(2) + Truth.assertThat(walletHeaders[0].walletName).isEqualTo("Wallet 1") + Truth.assertThat(walletHeaders[1].walletName).isEqualTo("Wallet 2") + Truth.assertThat(tokenGroups).hasSize(2) + } + + @Test + fun `should not show account headers when single account per wallet`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + Truth.assertThat(groups[0].accountHeader).isNull() + } + + @Test + fun `should show account headers when multiple accounts in same wallet`() { + val walletId = createMockUserWalletId("wallet1") + val accountId1 = createMockAccountId("account1") + val accountId2 = createMockAccountId("account2") + val entries = listOf( + createMockEntry(walletId, "Wallet 1", accountId1, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "Wallet 1", accountId2, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(2) + Truth.assertThat(groups[0].accountHeader).isNotNull() + Truth.assertThat(groups[1].accountHeader).isNotNull() + } + + @Test + fun `should group entries by wallet and then by account`() { + val walletId1 = createMockUserWalletId("wallet1") + val walletId2 = createMockUserWalletId("wallet2") + val account1 = createMockAccountId("acc1") + val account2 = createMockAccountId("acc2") + + val entries = listOf( + createMockEntry(walletId1, "W1", account1, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId1, "W1", account2, "eth", "Ethereum", "ETH"), + createMockEntry(walletId2, "W2", account1, "sol", "Solana", "SOL"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + // 2 wallet headers + 2 token groups for wallet1 (2 accounts) + 1 token group for wallet2 + val walletHeaders = result.sections.filterIsInstance() + val tokenGroups = result.sections.filterIsInstance() + + Truth.assertThat(walletHeaders).hasSize(2) + Truth.assertThat(tokenGroups).hasSize(3) + + // Wallet1 has 2 accounts so headers should be present + Truth.assertThat(tokenGroups[0].accountHeader).isNotNull() + Truth.assertThat(tokenGroups[1].accountHeader).isNotNull() + // Wallet2 has 1 account so no account header + Truth.assertThat(tokenGroups[2].accountHeader).isNull() + } + + @Test + fun `should correctly place multiple tokens in same account group`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "W1", accountId, "eth", "Ethereum", "ETH"), + createMockEntry(walletId, "W1", accountId, "sol", "Solana", "SOL"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + Truth.assertThat(groups[0].items).hasSize(3) + } + + @Test + fun `should preserve order of wallets and accounts`() { + val walletId1 = createMockUserWalletId("wallet1") + val walletId2 = createMockUserWalletId("wallet2") + val account1 = createMockAccountId("acc1") + val account2 = createMockAccountId("acc2") + + val entries = listOf( + createMockEntry(walletId1, "First Wallet", account1, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId1, "First Wallet", account2, "eth", "Ethereum", "ETH"), + createMockEntry(walletId2, "Second Wallet", account1, "sol", "Solana", "SOL"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val headers = result.sections.filterIsInstance() + Truth.assertThat(headers[0].walletName).isEqualTo("First Wallet") + Truth.assertThat(headers[1].walletName).isEqualTo("Second Wallet") + } + + @Test + fun `should convert entries to UserAssetItemUM Single`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + + val item = groups[0].items[0] + Truth.assertThat(item.tokenName).isEqualTo("Bitcoin") + Truth.assertThat(item.tokenSymbol).isEqualTo("BTC") + } + + @Test + fun `should ignore previous state and build from scratch`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"), + ) + + val prevStateWithSections = TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.WalletHeader(walletName = "Old Wallet"), + ), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevStateWithSections) + + val headers = result.sections.filterIsInstance() + Truth.assertThat(headers).isEmpty() + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + } + + // region Helpers + + private fun createTransformer(entries: List): BuildTokenSelectorSectionsTransformer { + return BuildTokenSelectorSectionsTransformer( + entries = entries, + appCurrency = appCurrency, + isBalanceHidden = false, + onTokenSelected = onTokenSelected, + ) + } + + private fun createMockUserWalletId(id: String): UserWalletId { + return mockk { + every { stringValue } returns id + } + } + + private fun createMockAccountId(id: String): AccountId { + return mockk { + every { value } returns id + } + } + + private fun createMockEntry( + walletId: UserWalletId = createMockUserWalletId("wallet1"), + walletName: String = "Wallet 1", + accountId: AccountId = createMockAccountId("account1"), + currencyId: String = "btc", + currencyName: String = "Bitcoin", + currencySymbol: String = "BTC", + ): UserAssetEntry { + val network = mockk(relaxed = true) { + every { name } returns "Network" + } + val currencyIdObj = mockk { + every { value } returns currencyId + } + val currency = mockk { + every { id } returns currencyIdObj + every { name } returns currencyName + every { symbol } returns currencySymbol + every { this@mockk.network } returns network + every { decimals } returns 8 + every { iconUrl } returns null + every { isCustom } returns false + } + val currencyStatus = mockk { + every { this@mockk.currency } returns currency + every { value } returns CryptoCurrencyStatus.Loaded( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + } + return mockk { + every { userWalletId } returns walletId + every { userWalletName } returns walletName + every { this@mockk.accountId } returns accountId + every { accountName } returns AccountName.DefaultMain + every { accountIcon } returns mockk(relaxed = true) + every { this@mockk.currencyStatus } returns currencyStatus + } + } + + // endregion +} \ No newline at end of file diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt new file mode 100644 index 0000000000..579609ec26 --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt @@ -0,0 +1,358 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.google.common.truth.Truth +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokenSelectorEntryConverterTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + private val onTokenSelected: (UserAssetEntry) -> Unit = mockk(relaxed = true) + + private lateinit var converter: TokenSelectorEntryConverter + + @BeforeEach + fun setup() { + clearMocks(onTokenSelected) + converter = TokenSelectorEntryConverter( + appCurrency = appCurrency, + isBalanceHidden = false, + onTokenSelected = onTokenSelected, + ) + } + + @Test + fun `should convert entry with Loaded status to Single with Loaded balance`() { + val entry = createMockEntry( + walletId = "wallet1", + accountId = "account1", + currencyId = "btc", + currencyName = "Bitcoin", + currencySymbol = "BTC", + networkName = "Bitcoin", + decimals = 8, + value = createLoadedValue( + amount = BigDecimal("1.5"), + fiatAmount = BigDecimal("45000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("2.5"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.id).isEqualTo("wallet1_account1_btc") + Truth.assertThat(result.tokenName).isEqualTo("Bitcoin") + Truth.assertThat(result.tokenSymbol).isEqualTo("BTC") + Truth.assertThat(result.networkName).isEqualTo("Bitcoin") + Truth.assertThat(result.isBalanceHidden).isFalse() + Truth.assertThat(result.balanceState).isInstanceOf(BalanceDisplayState.Loaded::class.java) + } + + @Test + fun `should set balance hidden when isBalanceHidden is true`() { + converter = TokenSelectorEntryConverter( + appCurrency = appCurrency, + isBalanceHidden = true, + onTokenSelected = onTokenSelected, + ) + + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.isBalanceHidden).isTrue() + } + + @Test + fun `should return Loading balance state for Loading value without amount`() { + val entry = createMockEntry( + value = CryptoCurrencyStatus.Loading, + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Loading) + } + + @Test + fun `should return Unreachable balance state for Unreachable value without amount`() { + val entry = createMockEntry( + value = createUnreachableValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Unreachable) + } + + @Test + fun `should return Unknown price change state for Loading value`() { + val entry = createMockEntry( + value = CryptoCurrencyStatus.Loading, + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Unknown price change state for Unreachable value`() { + val entry = createMockEntry( + value = createUnreachableValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Unknown price change state for MissedDerivation value`() { + val entry = createMockEntry( + value = createMissedDerivationValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Unknown price change state for NoAmount value`() { + val entry = createMockEntry( + value = createNoAmountValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Content price change state with UP type for positive change`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("5.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java) + val content = result.priceChangeState as PriceChangeState.Content + Truth.assertThat(content.type).isEqualTo(PriceChangeType.UP) + } + + @Test + fun `should return Content price change state with DOWN type for negative change`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("-3.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java) + val content = result.priceChangeState as PriceChangeState.Content + Truth.assertThat(content.type).isEqualTo(PriceChangeType.DOWN) + } + + @Test + fun `should invoke onTokenSelected callback when onClick is called`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ) + + val result = converter.convert(entry) + result.onClick() + + verify(exactly = 1) { onTokenSelected(entry) } + } + + @Test + fun `should generate correct composite id from wallet account and currency`() { + val entry = createMockEntry( + walletId = "myWallet", + accountId = "myAccount", + currencyId = "eth", + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.id).isEqualTo("myWallet_myAccount_eth") + } + + @Test + fun `should convert list of entries`() { + val entries = listOf( + createMockEntry(currencyId = "btc", currencyName = "Bitcoin", currencySymbol = "BTC"), + createMockEntry(currencyId = "eth", currencyName = "Ethereum", currencySymbol = "ETH"), + ) + + val results = converter.convertList(entries) + + Truth.assertThat(results).hasSize(2) + Truth.assertThat(results[0].tokenName).isEqualTo("Bitcoin") + Truth.assertThat(results[1].tokenName).isEqualTo("Ethereum") + } + + @Test + fun `should return fiatRate formatted string for Loaded value`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.fiatRate).isNotNull() + } + + @Test + fun `should return null fiatRate when value has no fiatRate`() { + val entry = createMockEntry( + value = CryptoCurrencyStatus.Loading, + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.fiatRate).isNull() + } + + // region Helpers + + private fun createMockEntry( + walletId: String = "wallet1", + accountId: String = "account1", + currencyId: String = "btc", + currencyName: String = "Bitcoin", + currencySymbol: String = "BTC", + networkName: String = "Bitcoin", + decimals: Int = 8, + value: CryptoCurrencyStatus.Value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ): UserAssetEntry { + val userWalletId = mockk { + every { stringValue } returns walletId + } + val accountIdMock = mockk { + every { this@mockk.value } returns accountId + } + val network = mockk(relaxed = true) { + every { name } returns networkName + } + val currencyIdObj = mockk { + every { this@mockk.value } returns currencyId + } + val currency = mockk { + every { id } returns currencyIdObj + every { name } returns currencyName + every { symbol } returns currencySymbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { iconUrl } returns null + every { isCustom } returns false + } + val currencyStatus = mockk { + every { this@mockk.currency } returns currency + every { this@mockk.value } returns value + } + return mockk { + every { this@mockk.userWalletId } returns userWalletId + every { this@mockk.userWalletName } returns "Wallet" + every { this@mockk.accountId } returns accountIdMock + every { this@mockk.accountName } returns mockk(relaxed = true) + every { this@mockk.accountIcon } returns mockk(relaxed = true) + every { this@mockk.currencyStatus } returns currencyStatus + } + } + + private fun createLoadedValue( + amount: BigDecimal, + fiatAmount: BigDecimal, + fiatRate: BigDecimal, + priceChange: BigDecimal, + ): CryptoCurrencyStatus.Loaded { + return CryptoCurrencyStatus.Loaded( + amount = amount, + fiatAmount = fiatAmount, + fiatRate = fiatRate, + priceChange = priceChange, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + } + + private fun createUnreachableValue(): CryptoCurrencyStatus.Unreachable { + return CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = null, + ) + } + + private fun createMissedDerivationValue(): CryptoCurrencyStatus.MissedDerivation { + return CryptoCurrencyStatus.MissedDerivation( + priceChange = null, + fiatRate = null, + ) + } + + private fun createNoAmountValue(): CryptoCurrencyStatus.NoAmount { + return CryptoCurrencyStatus.NoAmount( + priceChange = null, + fiatRate = null, + ) + } + + // endregion +} \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 97c6d2a93c..3e871cd61e 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -27,7 +27,6 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry @@ -69,7 +68,6 @@ internal class HomeModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, private val userWalletsListRepository: UserWalletsListRepository, - private val reduxStateHolder: ReduxStateHolder, private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -212,7 +210,6 @@ internal class HomeModel @Inject constructor( } }, ifRight = { - reduxStateHolder.onUserWalletSelected(userWallet) setLoading(false) sendSignedInCardAnalyticsEvent(scanResponse, userWallet.isImported) appRouter.replaceAll(AppRoute.Wallet) diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt index 84d2ec41e4..373418d3b3 100644 --- a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt @@ -1,6 +1,5 @@ package com.tangem.features.hotwallet interface HotWalletFeatureToggles { - val isWalletCreationRestrictionEnabled: Boolean - val isTokenSyncEnabled: Boolean + val isAssetsDiscoveryEnabled: Boolean } \ No newline at end of file diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 0daf9f2d1e..9d2eadef6e 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -38,7 +38,7 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) implementation(projects.domain.hotWallet) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) /** Common */ implementation(projects.common.ui) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt index 0eefa161bd..9c0d680f33 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt @@ -7,9 +7,6 @@ internal class DefaultHotWalletFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) : HotWalletFeatureToggles { - override val isWalletCreationRestrictionEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.HOT_WALLET_CREATION_RESTRICTION_ENABLED) - - override val isTokenSyncEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TOKEN_SYNC_ENABLED) + override val isAssetsDiscoveryEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.ASSETS_DISCOVERY_ENABLED) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt index 0be023b18b..ca7747292e 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -22,6 +23,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.HotWalletAccessCodeTestTags import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay @@ -109,6 +111,7 @@ internal fun AccessCode( pinTextColor = state.accessCodeColor, onValueChange = state.onAccessCodeChange, focusRequester = focusRequester, + modifier = Modifier.testTag(HotWalletAccessCodeTestTags.ACCESS_CODE_INPUT), ) } } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt index c71cf787d8..0a4666a24b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.settings.CanUseBiometryUseCase -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS @@ -39,7 +39,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val canUseBiometryUseCase: CanUseBiometryUseCase, private val analyticsEventHandler: AnalyticsEventHandler, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -220,8 +220,8 @@ internal class HotAccessCodeRequestModel @Inject constructor( val userWallet = userWalletsListRepository.userWalletsSync() .firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.cancel(userWallet.walletId) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.cancel(userWallet.walletId) } userWalletsListRepository.delete(listOf(userWallet.walletId)) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt index ed40d1e958..6c6efa8ece 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt @@ -17,9 +17,11 @@ import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.crypto.bip39.Mnemonic import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.common.wallets.error.SaveWalletError -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.builder.HotUserWalletBuilder +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.hotwallet.MnemonicRepository import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent @@ -27,6 +29,7 @@ import com.tangem.features.hotwallet.addexistingwallet.im.port.entity.AddExistin import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -42,7 +45,8 @@ internal class AddExistingWalletImportModel @Inject constructor( private val tangemHotSdk: TangemHotSdk, private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory, private val saveUserWalletUseCase: SaveWalletUseCase, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, @@ -114,8 +118,13 @@ internal class AddExistingWalletImportModel @Inject constructor( .onRight { setImportProgress(false) - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase(userWallet.walletId) + launch(dispatchers.main + NonCancellable) { + val syncResult = syncWalletWithRemoteUseCase(userWallet.walletId) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && + syncResult == WalletSyncResult.Created + ) { + startAssetsDiscoveryUseCase(userWallet.walletId) + } } analyticsEventHandler.send( diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt index d7bb9338b0..3b85b823dc 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt @@ -19,6 +19,7 @@ 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.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -39,6 +40,7 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.TangemTextFieldsDefault import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.test.ImportWalletScreenTestTags import com.tangem.features.hotwallet.addexistingwallet.im.port.ui.utils.InvalidWordsColorTransformation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -73,9 +75,10 @@ internal fun AddExistingWalletImportContent(state: AddExistingWalletImportUM, mo ) OutlineTextFieldWithIcon( - modifier = modifier + modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(ImportWalletScreenTestTags.PASSPHRASE_TEXT_FIELD), value = state.passPhrase, onValueChange = state.passPhraseChange, iconResId = R.drawable.ic_information_24, @@ -130,7 +133,8 @@ private fun PhraseBlock(state: AddExistingWalletImportUM, modifier: Modifier = M OutlinedTextField( modifier = Modifier .fillMaxWidth() - .height(TangemTheme.dimens.size142), + .height(TangemTheme.dimens.size142) + .testTag(ImportWalletScreenTestTags.PHRASE_TEXT_FIELD), value = state.words, onValueChange = state.wordsChange, textStyle = TangemTheme.typography.body1, diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt index fa801e998e..fea68a0a31 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt @@ -12,7 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.features.hotwallet.ForgetWalletComponent import com.tangem.features.hotwallet.HotWalletFeatureToggles @@ -33,7 +33,7 @@ internal class ForgetWalletModel @Inject constructor( private val router: Router, private val deleteWalletUseCase: DeleteWalletUseCase, private val uiMessageSender: UiMessageSender, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -84,8 +84,8 @@ internal class ForgetWalletModel @Inject constructor( private fun forgetWallet() { modelScope.launch { - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.cancel(params.userWalletId) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.cancel(params.userWalletId) } val hasUserWallets = deleteWalletUseCase(params.userWalletId) diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt index fcce6daea7..dc51c35411 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt @@ -9,6 +9,7 @@ enum class ManageTokensSource(val analyticsName: String) { ONBOARDING(analyticsName = "Onboarding"), SETTINGS(analyticsName = "Wallet Settings"), ACCOUNT(analyticsName = "Account"), + WALLET(analyticsName = "Wallet"), SEND_VIA_SWAP(analyticsName = "SendViaSwap"), } diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 867d3f035c..f2e5b7170c 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.swap.models) implementation(projects.domain.notifications) + implementation(projects.domain.dynamicAddresses) // region Project - Libs implementation(projects.libs.blockchainSdk) @@ -68,5 +69,4 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) implementation(deps.decompose.ext.compose) - implementation(deps.reKotlin) // need for legacy onboarding } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt index adef3798eb..cb0043af8a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt @@ -7,8 +7,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import arrow.core.getOrElse import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.managetokens.ValidateDerivationPathUseCase -import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent @@ -16,9 +17,12 @@ import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInput import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog +import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator +import com.tangem.features.managetokens.utils.DynamicAddressesDerivationValidator import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* @@ -26,9 +30,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr @Assisted context: AppComponentContext, @Assisted private val params: CustomTokenDerivationInputComponent.Params, private val validateDerivationPathUseCase: ValidateDerivationPathUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) : CustomTokenDerivationInputComponent, AppComponentContext by context { private val cardanoDerivationPathValidator = CardanoDerivationPathValidator() + private val dynamicAddressesDerivationValidator = DynamicAddressesDerivationValidator( + dynamicAddressesRepository = dynamicAddressesRepository, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, + ) private val state: MutableStateFlow = MutableStateFlow( value = getInitialState(), @@ -52,13 +62,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr ) } - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private fun observeValueUpdates() { state .map { it.value.text } .distinctUntilChanged() .sample(periodMillis = 1_000) - .onEach(::validateValue) + .flatMapLatest { value -> + flow { emit(validateValue(value)) } + } .launchIn(componentScope) } @@ -70,7 +82,7 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr onConfirm = ::confirm, ) - private fun validateValue(value: String) { + private suspend fun validateValue(value: String) { validateDerivationPathUseCase(value).getOrElse { e -> updateWithValidationError(e) return @@ -90,6 +102,21 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr return } + val isInvalidForDA = dynamicAddressesDerivationValidator.isInvalidForDynamicAddresses( + userWalletId = params.mode.userWalletId, + networkId = params.selectedNetwork.id, + path = value, + ) + if (isInvalidForDA) { + state.update { state -> + state.copy( + error = resourceReference(R.string.dynamic_addresses_custom_token_error_on_addition), + isConfirmEnabled = false, + ) + } + return + } + state.update { state -> state.copy( error = null, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt index ac0bd61222..1a7e88c79a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -57,7 +57,6 @@ internal class PreviewCustomTokenSelectorComponent( CurrencyNetworkUM( network = Network( id = n.id, - backendId = n.id.rawId.value, name = "Network $index", currencySymbol = "N$index", derivationPath = Network.DerivationPath.Card(""), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index ed67875a8e..9f7567aee0 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -154,7 +154,6 @@ internal class PreviewManageTokensComponent( CurrencyNetworkUM( network = Network( id = Network.ID(value = networkIndex.toString(), derivationPath = derivationPath), - backendId = networkIndex.toString(), name = "Network $networkIndex", currencySymbol = "N$networkIndex", derivationPath = derivationPath, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt index 7907f421fa..47fbb3f5e5 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt @@ -95,7 +95,6 @@ internal class PreviewOnboardingManageTokensComponent( CurrencyNetworkUM( network = Network( id = Network.ID(value = networkIndex.toString(), derivationPath = derivationPath), - backendId = networkIndex.toString(), name = "Network $networkIndex", currencySymbol = "N$networkIndex", derivationPath = derivationPath, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt index e62d320665..16c044adb6 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -183,7 +183,7 @@ internal class CustomTokenFormModel @Inject constructor( ) = modelScope.launch { val isNeedColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke( userWalletId = params.mode.userWalletId, - networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value), + networksWithDerivationPath = mapOf(currency.network.rawId to getDerivationPath().value), ) state.update { state -> diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index d0c6b34ea4..a4f71b8172 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -3,7 +3,7 @@ package com.tangem.features.managetokens.model import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -199,7 +199,7 @@ internal class CustomTokenSelectorModel @Inject constructor( private fun DerivationPathSelector.checkAccountDerivation(derivationPath: SelectedDerivationPath) = modelScope.launch { val account = derivationPath.id - ?.let { Blockchain.fromId(it.rawId.value) } + ?.toBlockchain() ?.let(::AccountNodeRecognizer) ?.let { recognizer -> val derivationPathValue = derivationPath.value.value diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt index 2c10b94d72..e767b7ab35 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -293,7 +293,7 @@ internal class ManageTokensModel @Inject constructor( val networks = currenciesToAdd.values .flatten() .toSet() - .associate { network -> network.backendId to network.derivationPath.value } + .associate { network -> network.rawId to network.derivationPath.value } val isNeedToInteractWithColdWallet = useCasesFacade.needColdWalletInteraction(networks) state.update { state -> diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt index 2e0e1b9a56..ceb222b45e 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt @@ -212,7 +212,7 @@ internal class OnboardingManageTokensModel @Inject constructor( val network = currenciesToAdd.values .flatten() .toSet() - .associate { network -> network.backendId to network.derivationPath.value } + .associate { network -> network.rawId to network.derivationPath.value } val shouldShowTangemIcon = useCasesFacade.needColdWalletInteraction(network = network) state.update { state -> state.copy( diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/DynamicAddressesDerivationValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/DynamicAddressesDerivationValidator.kt new file mode 100644 index 0000000000..a7f0a8a514 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/DynamicAddressesDerivationValidator.kt @@ -0,0 +1,51 @@ +package com.tangem.features.managetokens.utils + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.firstOrNull + +/** + * Validates that custom derivation paths don't conflict with Dynamic Addresses. + * + * When DA is enabled for an account, custom derivation paths with non-zero + * change (node 3) or address_index (node 4) are forbidden, because DA + * manages those nodes automatically. + */ +internal class DynamicAddressesDerivationValidator( + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, +) { + + /** + * @return true if the path is invalid (DA is enabled for the same account and change/index ≠ 0) + */ + suspend fun isInvalidForDynamicAddresses( + userWalletId: UserWalletId, + networkId: Network.ID, + path: String?, + ): Boolean { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false + if (path == null) return false + if (!isSupportedBlockchain(networkId)) return false + + val basePath = networkId.derivationPath.value ?: return false + val isEnabled = dynamicAddressesRepository + .isDynamicAddressesEnabledForNetwork(userWalletId, networkId) + .firstOrNull() == true + if (!isEnabled) return false + + return DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex( + customPath = path, + basePath = basePath, + ) + } + + private fun isSupportedBlockchain(networkId: Network.ID): Boolean { + return DynamicAddressesSupportedBlockchains.isSupported(networkId.toBlockchain()) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt index 96cc6f1e8a..e09585fdee 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt @@ -81,7 +81,7 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( private fun CryptoCurrency.getAccountIndex(): Either = either { val currency = this@getAccountIndex - val blockchain = Blockchain.fromNetworkId(networkId = currency.network.backendId) + val blockchain = Blockchain.fromNetworkId(networkId = currency.network.rawId) if (blockchain == null) { val exception = IllegalStateException("Token has unknown networkId: ${currency.id}") TangemLogger.e("Error", exception) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt index 63cab69a36..6271951a73 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt @@ -79,7 +79,7 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( ?: return IllegalStateException("Account not found").left() (account.cryptoCurrencies + added - removed).any { currency -> - currency is CryptoCurrency.Token && currency.network.backendId == network.backendId && + currency is CryptoCurrency.Token && currency.network.rawId == network.rawId && currency.network.derivationPath == network.derivationPath } .right() diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyNetworkOperations.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyNetworkOperations.kt index 77b26198be..4b2851b39e 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyNetworkOperations.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/ui/CurrencyNetworkOperations.kt @@ -1,8 +1,8 @@ package com.tangem.features.managetokens.utils.ui import androidx.annotation.DrawableRes -import com.tangem.core.ui.extensions.getActiveIconRes -import com.tangem.core.ui.extensions.getGreyedOutIconRes +import com.tangem.common.ui.extensions.greyedOutIconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.entity.item.CurrencyNetworkUM @@ -15,7 +15,7 @@ internal fun CurrencyNetworkUM.select(isSelected: Boolean): CurrencyNetworkUM { @DrawableRes internal fun Network.ID.getIconRes(isColored: Boolean): Int = if (isColored) { - getActiveIconRes(rawId.value) + this.iconResId } else { - getGreyedOutIconRes(rawId.value) + this.greyedOutIconResId } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt index 493d0af09c..66ce42ea67 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt @@ -1,25 +1,152 @@ package com.tangem.features.markets.token.block.impl.ui +import android.content.res.Configuration +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape 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.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.charts.MarketChartMini +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.ds.row.token.internal.TokenRowPriceChangeContent +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.R as CoreR +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.token.block.impl.model.formatter.toChartType import com.tangem.features.markets.token.block.impl.ui.state.TokenMarketBlockUM +import kotlinx.collections.immutable.toImmutableList +import kotlin.random.Random + +private val ChartWidth: Dp = 52.dp +private val ChartHeight: Dp = 32.dp -@Suppress("UnusedParameter") @Composable internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, + TangemRowContainer( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens2.x5)) + .background(TangemTheme.colors2.surface.level3), ) { Text( - text = "Market Block Redesign", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary1, + text = stringResourceSafe(id = R.string.markets_common_market_price), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + ) { + Text( + text = tokenMarketBlockUM.currentPrice.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.primary, + ) + TokenRowPriceChangeContent( + priceChangeState = PriceChangeState.Content( + type = tokenMarketBlockUM.priceChangeType, + valueInPercent = tokenMarketBlockUM.h24Percent.orEmpty(), + ), + isFlickering = false, + ) + } + + Box( + modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), + ) { + val chartModifier = Modifier.requiredSize(width = ChartWidth, height = ChartHeight) + + if (tokenMarketBlockUM.chartData != null) { + MarketChartMini( + rawData = tokenMarketBlockUM.chartData, + type = tokenMarketBlockUM.priceChangeType.toChartType(), + modifier = chartModifier, + ) + } else { + RectangleShimmer(modifier = chartModifier) + } + } + + SecondaryTangemButton( + onClick = tokenMarketBlockUM.onClick, + tangemIconUM = TangemIconUM.Icon(iconRes = CoreR.drawable.ic_arrow_expand_24), + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X10, + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x10), ) } -} \ No newline at end of file +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenMarketBlock_Preview( + @PreviewParameter(TokenMarketBlockPreviewProvider::class) params: TokenMarketBlockUM, +) { + TangemThemePreviewRedesign { + TokenMarketBlock( + tokenMarketBlockUM = params, + modifier = Modifier.background(TangemTheme.colors2.surface.level3), + ) + } +} + +private class TokenMarketBlockPreviewProvider : PreviewParameterProvider { + + private val data = MarketChartRawData( + x = List(size = 20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(size = 20) { Random.nextFloat().toDouble() }.toImmutableList(), + ) + + private val state = TokenMarketBlockUM( + currencySymbol = "XRP", + currentPrice = "0,5$", + currentPriceValue = null, + priceAnnotated = null, + h24Percent = "0,5%", + priceChangeType = PriceChangeType.UP, + chartData = data, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + state, + state.copy(currentPrice = "0,0000000000012356786789$"), + state.copy(currentPrice = null, chartData = null), + state.copy(chartData = null), + ) +} +// endregion \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt index 820fa36229..79c058b390 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt @@ -106,6 +106,7 @@ private fun LeftSide( modifier = Modifier.alignByBaseline(), valueInPercent = percentText, type = type, + textStyle = TangemTheme.typography.body2, ) Text( modifier = Modifier.alignByBaseline(), diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index be33a37a7f..797564dd0e 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -13,7 +13,7 @@ android { dependencies { /** Api */ - implementation(projects.features.account.api) + implementation(projects.features.commonFeatures.api) implementation(projects.features.nft.api) implementation(projects.features.tokenRecieve.api) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index 80e74ee49c..a7a42c0377 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -3,9 +3,13 @@ package com.tangem.features.nft.collections.entity.transformer import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.notifications.NotificationConfig -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.models.account.Account @@ -118,24 +122,24 @@ internal class UpdateDataStateTransformer( .map { it.collections.orEmpty().transform(this) } .flatten() - private fun List.transform(state: NFTCollectionsStateUM): ImmutableList = map { - NFTCollectionUM( - id = it.collectionIdProvider(), - networkIconId = getActiveIconRes(it.network.rawId), - name = it.name.orEmpty(), - description = TextReference.PluralRes( - R.plurals.nft_collections_count, - it.count, - wrappedList(it.count), - ), - logoUrl = it.logoUrl, - assets = it.transformAssets(), - onExpandClick = { - onExpandCollectionClick(it) - }, - isExpanded = it.isExpanded(state), - ) - }.toPersistentList() + private fun List.transform(state: NFTCollectionsStateUM): ImmutableList { + return map { collection -> + NFTCollectionUM( + id = collection.collectionIdProvider(), + networkIconId = collection.network.iconResId, + name = collection.name.orEmpty(), + description = TextReference.PluralRes( + R.plurals.nft_collections_count, + collection.count, + wrappedList(collection.count), + ), + logoUrl = collection.logoUrl, + assets = collection.transformAssets(), + onExpandClick = { onExpandCollectionClick(collection) }, + isExpanded = collection.isExpanded(state), + ) + }.toPersistentList() + } private fun transformNotifications(): ImmutableList = buildList { val nftCollections = walletNFTCollections?.flattenCollections diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt index 25d54e0d9f..2d94110b50 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -20,9 +20,9 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.nft.collections.NFTCollectionsComponent import com.tangem.features.nft.common.ui.NFTContent import com.tangem.features.nft.component.NFTComponent diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt index ff963aefcb..30cd672cd7 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.nft.component.NFTDetailsBlockComponent @@ -39,7 +39,7 @@ class DefaultNFTDetailsBlockComponent @AssistedInject constructor( AccountTitleUM.Text(params.walletTitle) }, isSuccessScreen = params.isSuccessScreen, - networkIconRes = getActiveIconRes(params.nftAsset.network.rawId), + networkIconRes = params.nftAsset.network.iconResId, ) } diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt index fc0f7c8e99..ca103c70e4 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt @@ -2,7 +2,7 @@ package com.tangem.features.nft.receive.entity.transformer import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.rows.model.ChainRowUM -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.domain.nft.models.NFTNetworks import com.tangem.features.nft.receive.entity.NFTNetworkUM @@ -41,7 +41,7 @@ internal class UpdateDataStateTransformer( type = "", icon = CurrencyIconState.CoinIcon( url = null, - fallbackResId = getActiveIconRes(rawId), + fallbackResId = iconResId, isGrayscale = !enabled, shouldShowCustomBadge = custom, ), diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt index 8a3ef86745..b714716c54 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.onboarding.v2 interface OnboardingV2FeatureToggles { val isVisaOnboardingEnabled: Boolean + val isAddressSyncEnabled: Boolean } \ No newline at end of file diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt index a21785815f..8407f8fe31 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt @@ -19,6 +19,7 @@ interface OnboardingEntryComponent : ComposableContentComponent { data object RecreateWalletTwin : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() + data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index 917e123120..889540badf 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -11,11 +11,16 @@ android { namespace = "com.tangem.features.onboarding.v2.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.onboardingV2.api) implementation(projects.features.manageTokens.api) implementation(projects.features.biometry.api) + implementation(projects.features.pushNotifications.api) implementation(projects.features.hotWallet.api) implementation(projects.features.tokenRecieve.api) @@ -37,6 +42,7 @@ dependencies { implementation(projects.common) /** Domain */ + implementation(projects.domain.account) implementation(projects.domain.models) implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) @@ -52,6 +58,7 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.onramp) implementation(projects.domain.transaction) + implementation(projects.domain.staking) /** Tangem libraries */ implementation(tangemDeps.hot.core) @@ -87,4 +94,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt index 6e165ef059..24c5fa9bbb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt @@ -9,4 +9,6 @@ internal class DefaultOnboardingV2FeatureToggles @Inject constructor( ) : OnboardingV2FeatureToggles { override val isVisaOnboardingEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.VISA_ONBOARDING_ENABLED) + override val isAddressSyncEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.ADDRESS_SYNC_ENABLED) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt index 58dc49d42d..bb607e5554 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt @@ -1,9 +1,9 @@ package com.tangem.features.onboarding.v2 -import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.StateFlow interface TitleProvider { - val currentTitle: StateFlow - fun changeTitle(text: TextReference) + val currentTitle: StateFlow + fun changeTitle(title: OnboardingTitle) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt new file mode 100644 index 0000000000..5b5db3568c --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt @@ -0,0 +1,7 @@ +package com.tangem.features.onboarding.v2.addresssync + +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface AddressSyncComponent : ComposableContentComponent { + data class Params(val isWalletStarted: Boolean) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt new file mode 100644 index 0000000000..564379e137 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -0,0 +1,188 @@ +package com.tangem.features.onboarding.v2.addresssync + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.DelicateDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.value.Value +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncIntent +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncState +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncButtonScreen +import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent +import com.tangem.features.onboarding.v2.addresssync.ui.loading.AddressSyncLoading +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.pushnotifications.api.PushNotificationsComponent +import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks +import com.tangem.features.pushnotifications.api.PushNotificationsParams + +@OptIn(DelicateDecomposeApi::class) +@Suppress("LongParameterList") +internal class DefaultAddressSyncComponent( + appComponentContext: AppComponentContext, + params: MultiWalletChildParams, + private val onBack: () -> Unit, + private val addressSyncParams: AddressSyncComponent.Params, + private val askBiometryComponentFactory: AskBiometryComponent.Factory, + private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, +) : AppComponentContext by appComponentContext, AddressSyncComponent { + + private val model: AddressSyncModel = getOrCreateModel(params) + + private val childStack: Value> = + childStack( + key = "innerStack", + source = model.stackNavigation, + serializer = null, + initialConfiguration = AddressSyncStep.LOADING, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + step = configuration, + childContext = childByContext(factoryContext), + ) + }, + ) + + init { + model.initConfiguration() + } + + @Composable + override fun Content(modifier: Modifier) { + BackHandler(enabled = true) { + onBack() + } + + val state by model.state.collectAsStateWithLifecycle() + + LaunchedEffect(state) { + when (state) { + AddressSyncState.Exit -> handleExit() + is AddressSyncState.Success -> { + val shouldExit = (state as AddressSyncState.Success).shouldExit + if (shouldExit) handleExit() + } + AddressSyncState.Loading -> Unit + } + } + + AddressSyncContent( + modifier = modifier, + childContent = { + Children( + stack = childStack, + modifier = Modifier.background( + color = TangemTheme.colors.background.primary, + ), + ) { child -> + child.instance.Content(Modifier) + } + }, + ) + } + + private fun handleExit() { + if (addressSyncParams.isWalletStarted) { + router.popTo(AppRoute.Wallet) + } else { + router.replaceAll(AppRoute.Wallet) + } + } + + private fun createChild(step: AddressSyncStep, childContext: AppComponentContext): ComposableContentComponent { + return when (step) { + AddressSyncStep.LOADING -> ComposableContentComponent { AddressSyncLoading() } + AddressSyncStep.ASK_BIOMETRY -> createAskBiometryComponent(childContext) + AddressSyncStep.ASK_NOTIFICATIONS -> createPushNotificationComponent(childContext) + AddressSyncStep.ADDRESS_SYNC -> ComposableContentComponent { + val state by model.state.collectAsStateWithLifecycle() + when (state) { + AddressSyncState.Loading -> AddressSyncLoading() + is AddressSyncState.Success -> AddressSyncButtonScreen( + state = state as AddressSyncState.Success, + onSyncClick = { + model.onIntent(AddressSyncIntent.Sync) + }, + ) + AddressSyncState.Exit -> AddressSyncLoading() + } + } + } + } + + private fun createAskBiometryComponent(childContext: AppComponentContext): AskBiometryComponent { + return askBiometryComponentFactory.create( + context = childContext, + params = AskBiometryComponent.Params( + isBottomSheetVariant = false, + modelCallbacks = object : AskBiometryComponent.ModelCallbacks { + override fun onAllowed() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ASK_NOTIFICATIONS, + ), + ) + } + + override fun onDenied() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ASK_NOTIFICATIONS, + ), + ) + } + }, + ), + ) + } + + private fun createPushNotificationComponent(childContext: AppComponentContext): PushNotificationsComponent { + return pushNotificationsComponentFactory.create( + context = childContext, + params = PushNotificationsParams( + modelCallbacks = object : PushNotificationsModelCallbacks { + override fun onAllowSystemPermission() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ADDRESS_SYNC, + ), + ) + } + + override fun onDenySystemPermission() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ADDRESS_SYNC, + ), + ) + } + + override fun onDismiss() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ADDRESS_SYNC, + ), + ) + } + }, + source = AppRoute.PushNotification.Source.Onboarding, + ), + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt new file mode 100644 index 0000000000..95549a70e6 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onboarding.v2.addresssync.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddressSyncModelModule { + + @Binds + @IntoMap + @ClassKey(AddressSyncModel::class) + fun bindAddressSyncModel(model: AddressSyncModel): Model +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt new file mode 100644 index 0000000000..fb8b5ed050 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt @@ -0,0 +1,21 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep + +internal sealed interface AddressSyncIntent { + data class Next(val step: AddressSyncStep) : AddressSyncIntent + data object Sync : AddressSyncIntent +} + +internal sealed class AddressSyncState { + data object Loading : AddressSyncState() + data class Success( + val currencies: List, + val isButtonLoading: Boolean = false, + val shouldExit: Boolean = false, + ) : AddressSyncState() { + val currenciesCount: Int = currencies.size + } + data object Exit : AddressSyncState() +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt new file mode 100644 index 0000000000..59aba1e675 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -0,0 +1,213 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import com.arkivanov.decompose.DelicateDecomposeApi +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.replaceCurrent +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.ShouldAskPermissionUseCase +import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher +import com.tangem.domain.tokens.MultiWalletAccountListFetcher +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.title.OnboardingTitle +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(DelicateDecomposeApi::class) +@Suppress("LongParameterList") +@ModelScoped +internal class AddressSyncModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, + private val canUseBiometryUseCase: CanUseBiometryUseCase, + private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher, + private val multiAccountListSupplier: MultiAccountListSupplier, + private val derivePublicKeysUseCase: DerivePublicKeysUseCase, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher, + private val stakingIdFactory: StakingIdFactory, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + private val walletId = (params.parentParams.mode as OnboardingMultiWalletComponent.Mode.AddressSync).userWalletId + val stackNavigation = StackNavigation() + val state: StateFlow + field = MutableStateFlow( + value = AddressSyncState.Loading, + ) + + init { + params.innerNavigation.value = MultiWalletInnerNavigationState( + stackSize = AddressSyncStep.ASK_BIOMETRY.pageNumber, + stackMaxSize = ADDRESS_SYNC_MAX_STEPS, + ) + fetchWalletCrypto() + } + + fun onIntent(intent: AddressSyncIntent) { + when (intent) { + is AddressSyncIntent.Next -> nextScreen(intent) + AddressSyncIntent.Sync -> startSyncing() + } + } + + private fun nextScreen(next: AddressSyncIntent.Next) { + stackNavigation.replaceCurrent(configuration = next.step) + updateStepperPage(next) + updateTitle(next) + modelScope.launch { tryToSkipNotificationScreen(next.step) } + } + + fun initConfiguration() { + modelScope.launch { + val shouldShowAskBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase() + val shouldShowAskNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) + val step = when { + !shouldShowAskBiometry && !shouldShowAskNotification -> AddressSyncStep.ADDRESS_SYNC + !shouldShowAskBiometry -> AddressSyncStep.ASK_NOTIFICATIONS + else -> AddressSyncStep.ASK_BIOMETRY + } + nextScreen(AddressSyncIntent.Next(step)) + } + } + + private suspend fun tryToSkipNotificationScreen(step: AddressSyncStep) { + when (step) { + AddressSyncStep.ASK_NOTIFICATIONS -> { + val shouldShowAskNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) + if (shouldShowAskNotification.not()) { + nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC)) + } + } + AddressSyncStep.LOADING, AddressSyncStep.ASK_BIOMETRY, AddressSyncStep.ADDRESS_SYNC -> Unit + } + } + + private fun updateStepperPage(next: AddressSyncIntent.Next) { + params.innerNavigation.update { innerNavigationState -> + innerNavigationState.copy( + stackSize = next.step.pageNumber, + ) + } + } + + private fun updateTitle(next: AddressSyncIntent.Next) { + next.step.stringId?.let { stringId -> + params.parentParams.titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(stringId), + shouldForceTitle = true, + ), + ) + } + } + + private fun fetchWalletCrypto() { + modelScope.launch { + multiWalletAccountListFetcher( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId), + ).fold( + ifLeft = { + state.value = AddressSyncState.Exit + }, + ifRight = { + handleAddressSyncStep() + }, + ) + } + } + + private suspend fun handleAddressSyncStep() { + multiAccountListSupplier() + .map { accountLists -> + accountLists + .first { it.userWalletId == walletId } + .flattenCurrencies() + } + .onEach { currencies -> + val updatedState = if (currencies.isEmpty()) { + AddressSyncState.Exit + } else { + AddressSyncState.Success(currencies = currencies) + } + state.value = updatedState + } + .collect() + } + + private fun startSyncing() { + modelScope.launch { + val successWithLoading = (state.value as AddressSyncState.Success).copy(isButtonLoading = true) + state.value = successWithLoading + val cryptoCurrencies = successWithLoading.currencies + derivePublicKeysUseCase( + userWalletId = walletId, + currencies = cryptoCurrencies, + ).fold( + ifLeft = { throwable -> + state.value = successWithLoading.copy( + isButtonLoading = false, + ) + TangemLogger.e("Failed to derive public keys", throwable) + }, + ifRight = { + listOf( + launch { fetchNetworks(cryptoCurrencies) }, + launch { fetchStaking(cryptoCurrencies) }, + ).joinAll() + state.update { addressSyncState -> + addressSyncState as AddressSyncState.Success + addressSyncState.copy(shouldExit = true) + } + }, + ) + } + } + + private suspend fun fetchNetworks(cryptoCurrencies: List) { + multiNetworkStatusFetcher.invoke( + MultiNetworkStatusFetcher.Params( + userWalletId = walletId, + networks = cryptoCurrencies.map(CryptoCurrency::network).toSet(), + ), + ) + .onLeft { TangemLogger.e("Unable to fetch networks: $it") } + } + + private suspend fun fetchStaking(cryptoCurrencies: List) { + val stakingIds = cryptoCurrencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = walletId, cryptoCurrency = it).getOrNull() + } + + multiStakingBalanceFetcher( + params = MultiStakingBalanceFetcher.Params( + userWalletId = walletId, + stakingIds = stakingIds, + ), + ) + .onLeft { TangemLogger.e("Unable to fetch yield balances: $it") } + } + + private companion object { + const val ADDRESS_SYNC_MAX_STEPS = 3 + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt new file mode 100644 index 0000000000..a0fc352db1 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt @@ -0,0 +1,11 @@ +package com.tangem.features.onboarding.v2.addresssync.navigation + +import androidx.annotation.StringRes +import com.tangem.features.onboarding.v2.impl.R + +internal enum class AddressSyncStep(val pageNumber: Int, @StringRes val stringId: Int?) { + LOADING(pageNumber = 0, stringId = null), + ASK_BIOMETRY(pageNumber = 1, stringId = R.string.onboarding_navbar_title_biometrics), + ASK_NOTIFICATIONS(pageNumber = 2, stringId = R.string.onboarding_title_notifications), + ADDRESS_SYNC(pageNumber = 3, stringId = R.string.onboarding_navbar_title_last_step), +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt new file mode 100644 index 0000000000..4b1bf6350c --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt @@ -0,0 +1,108 @@ +package com.tangem.features.onboarding.v2.addresssync.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SpacerHHalf +import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncState +import com.tangem.features.onboarding.v2.impl.R + +@Composable +internal fun AddressSyncButtonScreen( + state: AddressSyncState.Success, + onSyncClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(top = TangemTheme.dimens.spacing56), + ) { + SpacerHHalf() + Icon( + painter = painterResource(id = R.drawable.ic_sync_56), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(TangemTheme.dimens.size56), + ) + Text( + text = stringResourceSafe(R.string.onboarding_address_sync_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + start = TangemTheme.dimens.spacing34, + end = TangemTheme.dimens.spacing34, + top = TangemTheme.dimens.spacing28, + ), + ) + AddressSyncDescription(state.currenciesCount) + SpacerHMax() + PrimaryButtonIconEnd( + text = stringResourceSafe(R.string.common_generate_addresses), + onClick = onSyncClick, + iconResId = R.drawable.ic_tangem_24, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing154, + bottom = TangemTheme.dimens.spacing16, + ), + showProgress = state.isButtonLoading, + ) + } +} + +@Composable +private fun ColumnScope.AddressSyncDescription(currenciesCount: Int) { + Text( + text = pluralStringResourceSafe( + id = R.plurals.onboarding_address_sync_description, + count = currenciesCount, + currenciesCount, + ), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + start = TangemTheme.dimens.spacing34, + end = TangemTheme.dimens.spacing34, + top = TangemTheme.dimens.spacing28, + ), + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddressSyncButtonScreenPreview() { + TangemThemePreview { + AddressSyncButtonScreen( + state = AddressSyncState.Success( + currencies = emptyList(), + ), + onSyncClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt new file mode 100644 index 0000000000..9c0c693d4e --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.onboarding.v2.addresssync.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun AddressSyncContent(modifier: Modifier = Modifier, childContent: @Composable (Modifier) -> Unit = {}) { + Column( + modifier = modifier + .background(color = TangemTheme.colors.background.secondary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + childContent(Modifier) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddressSyncContentPreview() { + TangemThemePreview { + AddressSyncContent() + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt new file mode 100644 index 0000000000..f7c24fdd8a --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt @@ -0,0 +1,39 @@ +package com.tangem.features.onboarding.v2.addresssync.ui.loading + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun AddressSyncLoading(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size30) + .padding(4.dp), + color = TangemTheme.colors.icon.accent, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddressSyncLoadingPreview() { + TangemThemePreview { + AddressSyncLoading() + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt index ae53ea07e1..800b3b91e0 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt @@ -5,7 +5,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.subscribe @@ -22,6 +23,7 @@ import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute import com.tangem.features.onboarding.v2.entry.impl.ui.OnboardingEntry import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import dagger.assisted.Assisted @@ -111,8 +113,14 @@ internal class DefaultOnboardingEntryComponent @AssistedInject constructor( } }.saveIn(innerNavigationLinkJobHolder) } else { - stepperComponent.state.update { - it.copy( + val titleText = when (stack.active.configuration) { + is OnboardingRoute.ManageTokens -> resourceReference(R.string.main_manage_tokens) + is OnboardingRoute.AskBiometry -> resourceReference(R.string.onboarding_navbar_save_wallet) + is OnboardingRoute.Done -> resourceReference(R.string.onboarding_done_header) + else -> error("Unsupported route") + } + stepperComponent.state.update { stepperState -> + stepperState.copy( currentStep = when (stack.active.configuration) { is OnboardingRoute.ManageTokens -> 7 is OnboardingRoute.AskBiometry -> 8 @@ -120,12 +128,7 @@ internal class DefaultOnboardingEntryComponent @AssistedInject constructor( else -> error("Unsupported route") }, steps = 9, - title = when (stack.active.configuration) { - is OnboardingRoute.ManageTokens -> resourceReference(R.string.main_manage_tokens) - is OnboardingRoute.AskBiometry -> resourceReference(R.string.onboarding_navbar_save_wallet) - is OnboardingRoute.Done -> resourceReference(R.string.onboarding_done_header) - else -> error("Unsupported route") - }, + title = OnboardingTitle(titleText), showProgress = true, ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index d3a679ae26..70b1581b6f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -9,7 +9,6 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -26,6 +25,7 @@ import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent.Mode import com.tangem.features.onboarding.v2.entry.impl.analytics.OnboardingEntryEvent import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager @@ -51,9 +51,14 @@ internal class OnboardingEntryModel @Inject constructor( val stackNavigation = StackNavigation() val titleProvider = object : TitleProvider { - override val currentTitle = MutableStateFlow(stringReference("")) - override fun changeTitle(text: TextReference) { - currentTitle.value = text + override val currentTitle = MutableStateFlow( + OnboardingTitle( + text = stringReference(""), + ), + ) + + override fun changeTitle(title: OnboardingTitle) { + currentTitle.value = title } } @@ -82,6 +87,10 @@ internal class OnboardingEntryModel @Inject constructor( is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( userWalletId = mode.userWalletId, ) + is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync( + mode.userWalletId, + mode.isWalletStarted, + ) else -> error("Incorrect onboarding type") } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt index 268966cf50..a64e717f3b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt @@ -24,6 +24,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi data object AddBackup : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() + data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 6526071a17..c1c8142bb6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -25,8 +25,11 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerNavigation import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent +import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent @@ -42,6 +45,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiW import com.tangem.features.onboarding.v2.multiwallet.impl.ui.OnboardingMultiWallet import com.tangem.features.onboarding.v2.multiwallet.impl.ui.WalletArtworksState import com.tangem.features.onboarding.v2.util.ResetCardsComponent +import com.tangem.features.pushnotifications.api.PushNotificationsComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -53,6 +57,8 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor @Assisted private val params: OnboardingMultiWalletComponent.Params, private val analyticsHandler: AnalyticsEventHandler, private val resetCardsComponentFactory: ResetCardsComponent.Factory, + private val askBiometryComponentFactory: AskBiometryComponent.Factory, + private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, ) : OnboardingMultiWalletComponent, AppComponentContext by context { private val model: OnboardingMultiWalletModel = getOrCreateModel(params) @@ -60,6 +66,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor private val artworksState = instanceKeeper.getOrCreateSimple(key = "artworksState") { MutableStateFlow( when (model.state.value.currentStep) { + AddressSync -> WalletArtworksState.Hidden UpgradeWallet -> WalletArtworksState.Folded CreateWallet -> WalletArtworksState.Folded ChooseBackupOption -> WalletArtworksState.Fan @@ -186,6 +193,19 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor onBack = { model.onBack() }, onEvent = ::handleFinalizeComponentEvent, ) + AddressSync -> { + val mode = params.mode as OnboardingMultiWalletComponent.Mode.AddressSync + DefaultAddressSyncComponent( + appComponentContext = childContext, + params = childParams, + addressSyncParams = AddressSyncComponent.Params( + isWalletStarted = mode.isWalletStarted, + ), + onBack = { model.onBack() }, + askBiometryComponentFactory = askBiometryComponentFactory, + pushNotificationsComponentFactory = pushNotificationsComponentFactory, + ) + } Done -> error("Unexpected Done state") } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt index 26eb6e5be7..3a8e6451bf 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt @@ -12,6 +12,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.model.MultiWalletBackupModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.ui.MultiWalletBackup +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,7 +34,9 @@ class MultiWalletBackupComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), ) componentScope.launch { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt index 391228b718..8db18118a9 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt @@ -12,6 +12,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.chooseoption.model.Wallet1ChooseOptionModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.chooseoption.ui.Wallet1ChooseOption import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -32,7 +33,9 @@ class Wallet1ChooseOptionComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_getting_started), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_getting_started), + ), ) componentScope.launch { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt index 2cf4d4435f..c2644e4394 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt @@ -14,6 +14,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.model.MultiWalletCreateWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.MultiWalletCreateWallet import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -35,7 +36,9 @@ internal class MultiWalletCreateWalletComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_create_wallet_header), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_create_wallet_header), + ), ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt index 5bb2626fe5..d93f71cc47 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt @@ -14,6 +14,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model.MultiWalletFinalizeModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.MultiWalletFinalize +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.util.ResetCardsComponent import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.launchIn @@ -49,7 +50,9 @@ internal class MultiWalletFinalizeComponent( ) } params.parentParams.titleProvider.changeTitle( - resourceReference(R.string.onboarding_button_finalize_backup), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_button_finalize_backup), + ), ) componentScope.launch { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 61d26b089e..b6431a21af 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -265,6 +265,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( val userWallet = when (params.parentParams.mode) { OnboardingMultiWalletComponent.Mode.Onboarding, OnboardingMultiWalletComponent.Mode.ContinueFinalize, + is OnboardingMultiWalletComponent.Mode.AddressSync, -> { saveWalletUseCase.invoke( userWallet = userWalletCreated.copy( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt index 8e84c772ff..e302b74e1a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt @@ -10,6 +10,7 @@ import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.scanprimary.model.MultiWalletScanPrimaryModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.scanprimary.ui.MultiWalletScanPrimary +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -30,7 +31,9 @@ internal class MultiWalletScanPrimaryComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), ) componentScope.launch { model.onDone.collect { onDone() } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt index 2f85b1a45c..c58d8d27e6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt @@ -16,6 +16,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.MultiWalletSeedPhrase import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,17 +34,17 @@ internal class MultiWalletSeedPhraseComponent( init { componentScope.launch { - model.uiState.collect { + model.uiState.collect { state -> // change stepper state based on the stack of the current step @Suppress("MagicNumber") params.innerNavigation.update { st -> st.copy( - stackSize = 3 + it.order, + stackSize = 3 + state.order, stackMaxSize = 11, ) } - val title = when (it) { + val title = when (state) { is MultiWalletSeedPhraseUM.Import -> R.string.onboarding_seed_intro_button_import is MultiWalletSeedPhraseUM.GenerateSeedPhrase, is MultiWalletSeedPhraseUM.GeneratedWordsCheck, @@ -51,7 +52,11 @@ internal class MultiWalletSeedPhraseComponent( -> R.string.onboarding_create_wallet_header } - params.parentParams.titleProvider.changeTitle(text = resourceReference(title)) + params.parentParams.titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(title), + ), + ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt index 222b173ffb..3165bff4d4 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt @@ -14,6 +14,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model.MultiWalletUpgradeWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.MultiWalletUpgradeWallet import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -35,7 +36,9 @@ internal class MultiWalletUpgradeWalletComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.common_tangem), + OnboardingTitle( + text = resourceReference(R.string.common_tangem), + ), ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index d254fca350..fe78a9bb43 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.model +import com.tangem.common.routing.AppRoute import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -17,6 +18,7 @@ import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletCo import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMultiWalletUM +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.operations.attestation.ArtworkSize import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder @@ -70,7 +72,15 @@ internal class OnboardingMultiWalletModel @Inject constructor( onConfirm = { modelScope.launch { onboardingRepository.clearUnfinishedFinalizeOnboarding() - router.pop() + if (params.mode is OnboardingMultiWalletComponent.Mode.AddressSync) { + if (params.mode.isWalletStarted) { + router.popTo(AppRoute.Wallet) + } else { + router.replaceAll(AppRoute.Wallet) + } + } else { + router.pop() + } } }, ), @@ -120,8 +130,12 @@ internal class OnboardingMultiWalletModel @Inject constructor( params.mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { OnboardingMultiWalletState.Step.UpgradeWallet } - params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> + params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> { OnboardingMultiWalletState.Step.Finalize + } + params.mode is OnboardingMultiWalletComponent.Mode.AddressSync -> { + OnboardingMultiWalletState.Step.AddressSync + } // Add backup button // Wallet1 without backup and userwallet's scanResponse doesn't contain primary card. card.wallets.isNotEmpty() && card.backupStatus == CardDTO.BackupStatus.NoBackup && @@ -168,7 +182,9 @@ internal class OnboardingMultiWalletModel @Inject constructor( private fun initScreenTitle() { val title = screenTitleByStep(getInitialStep()) - params.titleProvider.changeTitle(title) + params.titleProvider.changeTitle( + title = OnboardingTitle(text = title), + ) } private fun loadCardArtwork() { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt index d1fa192d94..7da46f2dfb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt @@ -38,6 +38,7 @@ data class OnboardingMultiWalletState( SeedPhrase, ScanPrimary, AddBackupDevice, + AddressSync, Finalize, Done, } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt index 9a7b2c1476..5e4f6a5152 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt @@ -15,4 +15,5 @@ fun screenTitleByStep(step: OnboardingMultiWalletState.Step): TextReference = wh OnboardingMultiWalletState.Step.Finalize -> resourceReference(R.string.onboarding_button_finalize_backup) OnboardingMultiWalletState.Step.Done -> resourceReference(R.string.common_done) OnboardingMultiWalletState.Step.UpgradeWallet -> resourceReference(R.string.common_tangem) + OnboardingMultiWalletState.Step.AddressSync -> resourceReference(R.string.onboarding_navbar_title_biometrics) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt index 313506dfb3..b1ca27df6d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt @@ -27,6 +27,7 @@ import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteCommonSta import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel import com.tangem.features.onboarding.v2.note.impl.route.ONBOARDING_NOTE_STEPS_COUNT import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute +import com.tangem.features.onboarding.v2.title.OnboardingTitle import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -82,7 +83,11 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor( // sets title and stepper value childStack.subscribe(lifecycle) { stack -> val currentRoute = stack.active.configuration - params.titleProvider.changeTitle(TextReference.Res(R.string.onboarding_title)) + params.titleProvider.changeTitle( + title = OnboardingTitle( + TextReference.Res(R.string.onboarding_title), + ), + ) model.updateStepForNewRoute(currentRoute) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt index 8943397d3d..647af1b9ce 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt @@ -3,8 +3,8 @@ package com.tangem.features.onboarding.v2.stepper.api import androidx.annotation.IntRange import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.scan.ScanResponse +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.MutableStateFlow internal interface OnboardingStepperComponent : ComposableContentComponent { @@ -12,7 +12,7 @@ internal interface OnboardingStepperComponent : ComposableContentComponent { data class StepperState( @IntRange(from = 0) val currentStep: Int, @IntRange(from = 0) val steps: Int, - val title: TextReference, + val title: OnboardingTitle, val showProgress: Boolean, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt index e3e01fe44d..340fbe3fc2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt @@ -23,6 +23,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle @Composable internal fun OnboardingStepper( @@ -51,13 +52,12 @@ internal fun OnboardingStepper( startButton = TopAppBarButtonUM.Back(onBackClick), endButton = TopAppBarButtonUM.Icon(iconRes = R.drawable.ic_chat_24, onClicked = onSupportButtonClick) .takeIf { state.steps != state.currentStep }, - title = if (state.steps == state.currentStep) { + title = if (state.steps == state.currentStep && !state.title.shouldForceTitle) { resourceReference(R.string.common_done) } else { - state.title + state.title.text }, containerColor = TangemTheme.colors.background.primary, - modifier = modifier, ) TangemLinearProgressIndicator( @@ -89,7 +89,7 @@ private fun OnboardingStepper_Preview() { state = OnboardingStepperComponent.StepperState( currentStep = 2, steps = 3, - title = resourceReference(R.string.common_done), + title = OnboardingTitle(text = resourceReference(R.string.common_done)), showProgress = true, ), onBackClick = {}, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/title/OnboardingTitle.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/title/OnboardingTitle.kt new file mode 100644 index 0000000000..c71a088c59 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/title/OnboardingTitle.kt @@ -0,0 +1,8 @@ +package com.tangem.features.onboarding.v2.title + +import com.tangem.core.ui.extensions.TextReference + +data class OnboardingTitle( + val text: TextReference, + val shouldForceTitle: Boolean = false, +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt index 9879c7252b..838912346d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt @@ -12,6 +12,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigationHolder import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent import com.tangem.features.onboarding.v2.twin.impl.model.OnboardingTwinModel import com.tangem.features.onboarding.v2.twin.impl.ui.OnboardingTwin @@ -28,7 +29,11 @@ internal class DefaultOnboardingTwinComponent @AssistedInject constructor( private val model: OnboardingTwinModel = getOrCreateModel(params) init { - params.titleProvider.changeTitle(resourceReference(R.string.twins_recreate_toolbar)) + params.titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(R.string.twins_recreate_toolbar), + ), + ) } override val innerNavigation: InnerNavigation = object : InnerNavigation { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt index 047c16e1d5..3c6adb964d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt @@ -17,6 +17,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerNavigation import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.visa.api.OnboardingVisaComponent import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.approve.OnboardingVisaApproveComponent @@ -87,7 +88,11 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( // sets title and stepper value childStack.subscribe(lifecycle) { stack -> val currentRoute = stack.active.configuration - params.titleProvider.changeTitle(currentRoute.screenTitle()) + params.titleProvider.changeTitle( + title = OnboardingTitle( + text = currentRoute.screenTitle(), + ), + ) model.updateStepForNewRoute(currentRoute) } } diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt new file mode 100644 index 0000000000..9b9491aded --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -0,0 +1,360 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import arrow.core.Either +import com.arkivanov.decompose.router.stack.StackNavigation +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.ShouldAskPermissionUseCase +import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher +import com.tangem.domain.tokens.MultiWalletAccountListFetcher +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase +import com.tangem.features.onboarding.v2.TitleProvider +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.title.OnboardingTitle +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +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.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class AddressSyncModelTest { + + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase = mockk() + private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk() + private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase = mockk() + private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher = mockk() + private val multiAccountListSupplier: MultiAccountListSupplier = mockk() + private val derivePublicKeysUseCase: DerivePublicKeysUseCase = mockk() + private val paramsContainer: ParamsContainer = mockk() + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher = mockk() + private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + private val testInnerNavigation = MutableStateFlow( + value = MultiWalletInnerNavigationState( + stackSize = 0, + stackMaxSize = 0, + ) + ) + private val titleProvider: TitleProvider = mockk(relaxUnitFun = true) + private val walletId = UserWalletId("011") + private val params: MultiWalletChildParams = mockk { + every { innerNavigation } returns testInnerNavigation + every { parentParams } returns mockk { + every { titleProvider } returns this@AddressSyncModelTest.titleProvider + every { mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = walletId, + isWalletStarted = false, + ) + } + } + + @BeforeEach + fun setUp() { + coEvery { canUseBiometryUseCase.strict() } returns false + coEvery { shouldShowAskBiometryUseCase() } returns false + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + coEvery { multiWalletAccountListFetcher.invoke(any()) } returns Either.Right(Unit) + coEvery { derivePublicKeysUseCase(any(), any()) } returns Either.Right(Unit) + every { multiAccountListSupplier() } returns flowOf( + listOf(AccountList.empty(userWalletId = walletId)), + ) + every { paramsContainer.require() } returns params + } + + @Test + fun `WHEN model is created THEN inner navigation stack size and max size are set`() = runTest { + createModel(this) + + val state = testInnerNavigation.value + Assertions.assertEquals(AddressSyncStep.ASK_BIOMETRY.pageNumber, state.stackSize) + Assertions.assertEquals(3, state.stackMaxSize) + } + + @Test + fun `GIVEN biometry allowed AND should show biometry WHEN initConfiguration THEN ASK_BIOMETRY on top`() = runTest { + coEvery { canUseBiometryUseCase.strict() } returns true + coEvery { shouldShowAskBiometryUseCase() } returns true + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.initConfiguration() + advanceUntilIdle() + + Assertions.assertEquals(listOf(AddressSyncStep.ASK_BIOMETRY), stack) + assertStepperAndTitleFor(AddressSyncStep.ASK_BIOMETRY) + } + + @Test + fun `GIVEN biometry not needed AND notifications required WHEN initConfiguration THEN ASK_NOTIFICATIONS on top`() = + runTest { + coEvery { canUseBiometryUseCase.strict() } returns false + coEvery { shouldShowAskBiometryUseCase() } returns false + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns true + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.initConfiguration() + advanceUntilIdle() + + Assertions.assertEquals(listOf(AddressSyncStep.ASK_NOTIFICATIONS), stack) + assertStepperAndTitleFor(AddressSyncStep.ASK_NOTIFICATIONS) + } + + @Test + fun `GIVEN biometry not needed AND notifications skipped WHEN initConfiguration THEN ADDRESS_SYNC on top`() = + runTest { + coEvery { canUseBiometryUseCase.strict() } returns false + coEvery { shouldShowAskBiometryUseCase() } returns false + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.initConfiguration() + advanceUntilIdle() + + Assertions.assertEquals(listOf(AddressSyncStep.ADDRESS_SYNC), stack) + assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) + } + + @Test + fun `GIVEN notifications required WHEN Next ASK_NOTIFICATIONS THEN ASK_NOTIFICATIONS on top`() = runTest { + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns true + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) + advanceUntilIdle() + + Assertions.assertEquals(listOf(AddressSyncStep.ASK_NOTIFICATIONS), stack) + assertStepperAndTitleFor(AddressSyncStep.ASK_NOTIFICATIONS) + } + + @Test + fun `GIVEN notifications skipped WHEN Next ASK_NOTIFICATIONS THEN ADDRESS_SYNC on top`() = runTest { + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) + advanceUntilIdle() + + Assertions.assertEquals(listOf(AddressSyncStep.ADDRESS_SYNC), stack) + assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) + } + + @Test + fun `GIVEN multiAccountListSupplier emits no currencies WHEN model is created THEN state is Exit`() = runTest { + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = emptyList(), + ), + ), + ) + + val model = createModel(this) + advanceUntilIdle() + + coVerify { + multiWalletAccountListFetcher.invoke( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) + ) + } + Assertions.assertEquals(AddressSyncState.Exit, model.state.value) + } + + @Test + fun `GIVEN multiAccountListSupplier emits currencies WHEN model is created THEN state is Success`() = runTest { + val currencies = listOf(mockk(), mockk(), mockk()) + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + + val model = createModel(this) + advanceUntilIdle() + + coVerify { + multiWalletAccountListFetcher.invoke( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) + ) + } + Assertions.assertEquals( + AddressSyncState.Success(currencies), + model.state.value, + ) + } + + @Test + fun `WHEN multiWalletAccountListFetcher emits error WHEN model is created THEN get Exit state`() = runTest { + val currencies = listOf(mockk(), mockk(), mockk()) + coEvery { multiWalletAccountListFetcher.invoke(any()) } returns Either.Left( + value = IllegalStateException("Test") + ) + + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + + val model = createModel(this) + advanceUntilIdle() + + coVerify { + multiWalletAccountListFetcher.invoke( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) + ) + } + Assertions.assertEquals( + AddressSyncState.Exit, + model.state.value, + ) + } + + @Test + fun `GIVEN success state WHEN Sync THEN state becomes Success with shouldExit`() = runTest { + val currencies = listOf( + mockk { every { network } returns mockk() }, + mockk { every { network } returns mockk() }, + mockk { every { network } returns mockk() }, + ) + val expected = AddressSyncState.Success(currencies, isButtonLoading = true, shouldExit = true) + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + coEvery { derivePublicKeysUseCase(walletId, currencies) } returns Either.Right(Unit) + coEvery { multiNetworkStatusFetcher.invoke(any()) } returns Either.Right(Unit) + coEvery { + stakingIdFactory.create(userWalletId = walletId, cryptoCurrency = any()) + } returns Either.Right(mockk()) + coEvery { multiStakingBalanceFetcher(any()) } returns Either.Right(Unit) + + val model = createModel(this) + advanceUntilIdle() + + model.onIntent(AddressSyncIntent.Sync) + advanceUntilIdle() + + coVerify { derivePublicKeysUseCase(walletId, currencies) } + coVerify { multiNetworkStatusFetcher.invoke(any()) } + coVerify { multiStakingBalanceFetcher(any()) } + Assertions.assertEquals(expected, model.state.value) + } + + @Test + fun `GIVEN success state WHEN Sync AND derive fails THEN button loading is reset`() = runTest { + val currencies = listOf(mockk(), mockk(), mockk()) + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + coEvery { derivePublicKeysUseCase(walletId, currencies) } returns Either.Left( + value = IllegalStateException("Test"), + ) + + val model = createModel(this) + advanceUntilIdle() + + model.onIntent(AddressSyncIntent.Sync) + advanceUntilIdle() + + coVerify { derivePublicKeysUseCase(walletId, currencies) } + Assertions.assertEquals( + AddressSyncState.Success(currencies = currencies, isButtonLoading = false), + model.state.value, + ) + } + + private fun assertStepperAndTitleFor(step: AddressSyncStep) { + Assertions.assertEquals(step.pageNumber, testInnerNavigation.value.stackSize) + verify { + titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(step.stringId!!), + shouldForceTitle = true, + ), + ) + } + } + + private fun StackNavigation.trackStack(): List { + val tracked = mutableListOf() + subscribe { event -> + val newStack = event.transformer(tracked.toList()) + tracked.clear() + tracked.addAll(newStack) + } + return tracked + } + + private fun createModel(testScope: TestScope): AddressSyncModel { + return AddressSyncModel( + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + shouldShowAskBiometryUseCase = shouldShowAskBiometryUseCase, + canUseBiometryUseCase = canUseBiometryUseCase, + shouldAskPermissionUseCase = shouldAskPermissionUseCase, + multiWalletAccountListFetcher = multiWalletAccountListFetcher, + multiAccountListSupplier = multiAccountListSupplier, + derivePublicKeysUseCase = derivePublicKeysUseCase, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiStakingBalanceFetcher = multiStakingBalanceFetcher, + stakingIdFactory = stakingIdFactory, + paramsContainer = paramsContainer, + ) + } + + 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/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt new file mode 100644 index 0000000000..db8b3ae7df --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt @@ -0,0 +1,344 @@ +package com.tangem.features.onboarding.v2.entry.impl.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog +import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent +import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent +import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +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.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import kotlin.reflect.KClass + +@OptIn(ExperimentalCoroutinesApi::class) +internal class OnboardingEntryModelTest { + + private val router: Router = mockk(relaxUnitFun = true) + private val tangemSdkManager: TangemSdkManager = mockk() + private val settingsRepository: SettingsRepository = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) + private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val paramsContainer: ParamsContainer = mockk() + + private val scanResponse: ScanResponse = mockk() + private val params: OnboardingEntryComponent.Params = mockk { + every { scanResponse } returns this@OnboardingEntryModelTest.scanResponse + } + + @BeforeEach + fun setUp() { + every { paramsContainer.require() } returns params + every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding + every { scanResponse.productType } returns ProductType.Wallet + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + coEvery { settingsRepository.shouldShowAskBiometry() } returns false + } + + @ParameterizedTest + @MethodSource("provideStartRouteByProductType") + fun `GIVEN product type WHEN model is created THEN startRoute is of expected type`( + productType: ProductType, + expectedRouteClass: KClass, + ) = runTest { + every { scanResponse.productType } returns productType + every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding + + val model = createModel(this) + + Assertions.assertTrue( + expectedRouteClass.isInstance(model.startRoute), + "Expected ${expectedRouteClass.simpleName} but got ${model.startRoute::class.simpleName}", + ) + } + + @ParameterizedTest + @MethodSource("provideWallet2ModeMappings") + fun `GIVEN Wallet2 AND entry mode WHEN model is created THEN multi-wallet mode is mapped`( + entryMode: OnboardingEntryComponent.Mode, + expectedMultiWalletMode: OnboardingMultiWalletComponent.Mode, + ) = runTest { + every { scanResponse.productType } returns ProductType.Wallet2 + every { params.mode } returns entryMode + + val model = createModel(this) + + val route = model.startRoute as OnboardingRoute.MultiWallet + Assertions.assertEquals(expectedMultiWalletMode, route.mode) + Assertions.assertEquals(true, route.withSeedPhraseFlow) + } + + @ParameterizedTest + @MethodSource("provideTwinsModeMappings") + fun `GIVEN Twins AND entry mode WHEN model is created THEN twin mode is mapped`( + entryMode: OnboardingEntryComponent.Mode, + expectedTwinMode: OnboardingTwinComponent.Params.Mode, + ) = runTest { + every { scanResponse.productType } returns ProductType.Twins + every { params.mode } returns entryMode + + val model = createModel(this) + + val route = model.startRoute as OnboardingRoute.Twins + Assertions.assertEquals(expectedTwinMode, route.mode) + } + + @Test + fun `GIVEN Wallet WHEN model is created THEN withSeedPhraseFlow is false`() = runTest { + every { scanResponse.productType } returns ProductType.Wallet + every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding + + val model = createModel(this) + + val route = model.startRoute as OnboardingRoute.MultiWallet + Assertions.assertEquals(false, route.withSeedPhraseFlow) + } + + @Test + fun `GIVEN biometry available AND should ask WHEN onManageTokensDone THEN AskBiometry replaces stack`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns true + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onManageTokensDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + Assertions.assertTrue(stack.first() is OnboardingRoute.AskBiometry) + } + + @Test + fun `GIVEN biometry not available WHEN onManageTokensDone THEN Done WalletCreated replaces stack`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onManageTokensDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + val route = stack.first() + Assertions.assertTrue(route is OnboardingRoute.Done) + Assertions.assertEquals(OnboardingDoneComponent.Mode.WalletCreated, (route as OnboardingRoute.Done).mode) + } + + @Test + fun `GIVEN biometry available AND should not ask WHEN onManageTokensDone THEN Done replaces stack`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onManageTokensDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + Assertions.assertTrue(stack.first() is OnboardingRoute.Done) + } + + @Test + fun `GIVEN Visa AND biometry available WHEN onManageTokensDone THEN BiometricScreenOpened analytics is sent`() = + runTest { + every { scanResponse.productType } returns ProductType.Visa + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns true + + val model = createModel(this) + + model.onManageTokensDone() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(match { true }) + } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `GIVEN Visa AND biometry not available WHEN onManageTokensDone THEN SuccessScreenOpened analytics is sent`() = + runTest { + every { scanResponse.productType } returns ProductType.Visa + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + + val model = createModel(this) + + model.onManageTokensDone() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(match { true }) + } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `GIVEN non-Visa WHEN onManageTokensDone THEN no Visa analytics sent`() = runTest { + every { scanResponse.productType } returns ProductType.Wallet2 + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns true + + val model = createModel(this) + + model.onManageTokensDone() + advanceUntilIdle() + + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `WHEN onBack THEN CantLeaveBackupDialog is sent`() = runTest { + val model = createModel(this) + + model.onBack() + + verify { uiMessageSender.send(CantLeaveBackupDialog) } + } + + @Test + fun `WHEN onboardingTwinModelCallbacks onBack THEN router pop is called`() = runTest { + val model = createModel(this) + + model.onboardingTwinModelCallbacks.onBack() + + verify { router.pop(onComplete = any()) } + } + + @Test + fun `WHEN onboardingTwinModelCallbacks onDone THEN navigateToFinalScreenFlow runs`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onboardingTwinModelCallbacks.onDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + val route = stack.first() + Assertions.assertTrue(route is OnboardingRoute.Done) + Assertions.assertEquals(OnboardingDoneComponent.Mode.WalletCreated, (route as OnboardingRoute.Done).mode) + } + + private fun StackNavigation.trackStack(): List { + val tracked = mutableListOf() + subscribe { event -> + val newStack = event.transformer(tracked.toList()) + tracked.clear() + tracked.addAll(newStack) + } + return tracked + } + + private fun createModel(testScope: TestScope): OnboardingEntryModel { + return OnboardingEntryModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + tangemSdkManager = tangemSdkManager, + settingsRepository = settingsRepository, + analyticsEventHandler = analyticsEventHandler, + uiMessageSender = uiMessageSender, + userWalletsListRepository = userWalletsListRepository, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + companion object { + + @JvmStatic + fun provideStartRouteByProductType(): List = listOf( + Arguments.of(ProductType.Wallet, OnboardingRoute.MultiWallet::class), + Arguments.of(ProductType.Wallet2, OnboardingRoute.MultiWallet::class), + Arguments.of(ProductType.Ring, OnboardingRoute.MultiWallet::class), + Arguments.of(ProductType.Note, OnboardingRoute.Note::class), + Arguments.of(ProductType.Twins, OnboardingRoute.Twins::class), + Arguments.of(ProductType.Visa, OnboardingRoute.Visa::class), + ) + + @JvmStatic + fun provideWallet2ModeMappings(): List { + val userWalletId = UserWalletId("011") + return listOf( + Arguments.of( + OnboardingEntryComponent.Mode.Onboarding, + OnboardingMultiWalletComponent.Mode.Onboarding, + ), + Arguments.of( + OnboardingEntryComponent.Mode.AddBackupWallet1, + OnboardingMultiWalletComponent.Mode.AddBackup, + ), + Arguments.of( + OnboardingEntryComponent.Mode.ContinueFinalize, + OnboardingMultiWalletComponent.Mode.ContinueFinalize, + ), + Arguments.of( + OnboardingEntryComponent.Mode.UpgradeHotWallet(userWalletId), + OnboardingMultiWalletComponent.Mode.UpgradeHotWallet(userWalletId), + ), + Arguments.of( + OnboardingEntryComponent.Mode.AddressSync(userWalletId, isWalletStarted = true), + OnboardingMultiWalletComponent.Mode.AddressSync(userWalletId, isWalletStarted = true), + ), + ) + } + + @JvmStatic + fun provideTwinsModeMappings(): List = listOf( + Arguments.of( + OnboardingEntryComponent.Mode.Onboarding, + OnboardingTwinComponent.Params.Mode.CreateWallet, + ), + Arguments.of( + OnboardingEntryComponent.Mode.WelcomeOnlyTwin, + OnboardingTwinComponent.Params.Mode.WelcomeOnly, + ), + Arguments.of( + OnboardingEntryComponent.Mode.RecreateWalletTwin, + OnboardingTwinComponent.Params.Mode.RecreateWallet, + ), + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt new file mode 100644 index 0000000000..92840e92da --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt @@ -0,0 +1,503 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model + +import com.tangem.common.CompletionResult +import com.tangem.common.card.Card +import com.tangem.common.core.TangemSdkError +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onboarding.repository.OnboardingRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase +import com.tangem.domain.wallets.usecase.UpdateWalletUseCase +import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.MultiWalletFinalizeComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state.MultiWalletFinalizeUM +import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.operations.backup.BackupService +import com.tangem.sdk.api.BackupServiceHolder +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +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.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.lang.ref.WeakReference + +@OptIn(ExperimentalCoroutinesApi::class) +internal class MultiWalletFinalizeModelTest { + + private val backupServiceHolder: BackupServiceHolder = mockk() + private val backupService: BackupService = mockk() + private val backupServiceWeakRef: WeakReference = WeakReference(backupService) + private val tangemSdkManager: TangemSdkManager = mockk(relaxUnitFun = true) + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk() + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk() + private val saveWalletUseCase: SaveWalletUseCase = mockk() + private val getUserWalletsUseCase: GetWalletsUseCase = mockk() + private val updateWalletUseCase: UpdateWalletUseCase = mockk() + private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase = mockk() + private val cardRepository: CardRepository = mockk() + private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true) + private val walletsRepository: WalletsRepository = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val backupValidator: BackupValidator = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) + private val paramsContainer: ParamsContainer = mockk() + + private val scanResponse: ScanResponse = mockk() + + private val multiWalletStateFlow = MutableStateFlow( + OnboardingMultiWalletState( + currentStep = OnboardingMultiWalletState.Step.Finalize, + accessCode = null, + isThreeCards = true, + currentScanResponse = scanResponse, + startFromFinalize = null, + resultUserWallet = null, + ), + ) + + private val parentParams: OnboardingMultiWalletComponent.Params = mockk { + every { mode } returns OnboardingMultiWalletComponent.Mode.Onboarding + every { scanResponse } returns this@MultiWalletFinalizeModelTest.scanResponse + } + + private val params: MultiWalletChildParams = mockk { + every { multiWalletState } returns multiWalletStateFlow + every { parentParams } returns this@MultiWalletFinalizeModelTest.parentParams + } + + @BeforeEach + fun setUp() { + every { paramsContainer.require() } returns params + every { backupServiceHolder.backupService } returns backupServiceWeakRef + every { backupService.primaryCardId } returns "primary-id-aaaa" + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + every { backupService.backupCardIds } returns listOf("backup-1-bbbb", "backup-2-cccc") + every { backupService.backupCardsBatchIds } returns listOf(NON_RING_BATCH_ID, NON_RING_BATCH_ID) + every { backupService.currentState } returns BackupService.State.FinalizingPrimaryCard + coEvery { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } just Runs + } + + @Test + fun `WHEN init AND startFromFinalize is null THEN no events emitted`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null) + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + Assertions.assertEquals(emptyList(), events) + } + + @Test + fun `WHEN init AND startFromFinalize is ScanBackupFirstCard THEN OneBackupCardAdded emitted`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + Assertions.assertEquals( + listOf(MultiWalletFinalizeComponent.Event.OneBackupCardAdded), + events, + ) + } + + @Test + fun `WHEN init AND startFromFinalize is ScanBackupSecondCard THEN both events emitted in order`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupSecondCard, + ) + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + Assertions.assertEquals( + listOf( + MultiWalletFinalizeComponent.Event.OneBackupCardAdded, + MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded, + ), + events, + ) + } + + @Test + fun `GIVEN backupService is null WHEN model is created THEN initial state is default`() = runTest { + every { backupServiceHolder.backupService } returns WeakReference(null) + + val model = createModel(this) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step) + Assertions.assertEquals(true, state.scanPrimary) + Assertions.assertEquals("", state.cardNumber) + Assertions.assertEquals(false, state.isRing) + } + + @Test + fun `GIVEN startFromFinalize null AND non-Ring primary WHEN model is created THEN state is Primary non-Ring`() = + runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null) + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + + val model = createModel(this) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step) + Assertions.assertEquals(true, state.scanPrimary) + Assertions.assertEquals(false, state.isRing) + Assertions.assertEquals("primary-id-aaaa".lastMaskedExpected(), state.cardNumber) + } + + @Test + fun `GIVEN startFromFinalize ScanPrimaryCard AND Ring primary WHEN model is created THEN state is Primary Ring`() = + runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanPrimaryCard, + ) + every { backupService.primaryCardBatchId } returns RING_BATCH_ID_AC17 + + val model = createModel(this) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step) + Assertions.assertEquals(true, state.scanPrimary) + Assertions.assertEquals(true, state.isRing) + } + + @Test + fun `GIVEN startFromFinalize ScanBackupFirstCard WHEN model is created THEN state is BackupDevice1`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice1, state.step) + Assertions.assertEquals(false, state.scanPrimary) + Assertions.assertEquals("backup-1-bbbb".lastMaskedExpected(), state.cardNumber) + } + + @Test + fun `GIVEN startFromFinalize ScanBackupSecondCard WHEN model is created THEN state is BackupDevice2`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupSecondCard, + ) + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step) + Assertions.assertEquals(false, state.scanPrimary) + Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber) + } + + @Test + fun `GIVEN scanPrimary true WHEN onBack THEN onBackFlow emits Unit`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null) + + val model = createModel(this) + val received = mutableListOf() + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onBackFlow.collect { received.add(it) } } + advanceUntilIdle() + + model.onBack() + advanceUntilIdle() + + Assertions.assertEquals(listOf(Unit), received) + verify(exactly = 0) { uiMessageSender.send(CantLeaveBackupDialog) } + } + + @Test + fun `GIVEN scanPrimary false WHEN onBack THEN CantLeaveBackupDialog is sent`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.onBack() + advanceUntilIdle() + + verify { uiMessageSender.send(CantLeaveBackupDialog) } + } + + @Test + fun `GIVEN primary batchId is null WHEN onScanClick THEN proceedBackup is not called`() = runTest { + every { backupService.primaryCardBatchId } returns null + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { backupService.proceedBackup(iconScanRes = any(), callback = any()) } + verify(exactly = 0) { tangemSdkManager.changeProductType(any()) } + } + + @Test + fun `GIVEN non-Ring primary AND success WHEN onScanClick THEN state moves to BackupDevice1`() = runTest { + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { tangemSdkManager.changeProductType(false) } + verify { backupService.proceedBackup(iconScanRes = null, callback = any()) } + + callbackSlot.captured.invoke(CompletionResult.Success(mockk())) + advanceUntilIdle() + + verify { tangemSdkManager.clearProductType() } + coVerify { onboardingRepository.saveUnfinishedFinalizeOnboarding(scanResponse = scanResponse) } + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice1, state.step) + Assertions.assertEquals(false, state.scanPrimary) + Assertions.assertEquals("backup-1-bbbb".lastMaskedExpected(), state.cardNumber) + Assertions.assertEquals(false, state.isRing) + Assertions.assertEquals( + listOf(MultiWalletFinalizeComponent.Event.OneBackupCardAdded), + events, + ) + } + + @Test + fun `GIVEN Ring primary WHEN onScanClick THEN changeProductType is true and ring icon is used`() = runTest { + every { backupService.primaryCardBatchId } returns RING_BATCH_ID_AC17 + every { + backupService.proceedBackup(iconScanRes = any(), callback = any()) + } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { tangemSdkManager.changeProductType(true) } + verify { + backupService.proceedBackup( + iconScanRes = com.tangem.features.onboarding.v2.impl.R.drawable.img_hand_scan_ring, + callback = any(), + ) + } + } + + @Test + fun `GIVEN primary AND failure WHEN onScanClick THEN state is unchanged AND no event emitted`() = runTest { + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + val stateBefore = model.uiState.value + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.UserCancelled())) + advanceUntilIdle() + + verify { tangemSdkManager.clearProductType() } + coVerify(exactly = 0) { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } + Assertions.assertEquals(stateBefore.step, model.uiState.value.step) + Assertions.assertEquals(stateBefore.scanPrimary, model.uiState.value.scanPrimary) + Assertions.assertEquals(emptyList(), events) + } + + @Test + fun `GIVEN BackupDevice1 AND batchId null WHEN onScanClick THEN proceedBackup is not called`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + every { backupService.backupCardsBatchIds } returns emptyList() + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { backupService.proceedBackup(iconScanRes = any(), callback = any()) } + } + + @Test + fun `GIVEN BackupDevice1 AND failure WalletAlreadyCreated WHEN onScanClick THEN dialog is set`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.WalletAlreadyCreated())) + advanceUntilIdle() + + Assertions.assertNotNull(model.uiState.value.dialog) + verify { tangemSdkManager.clearProductType() } + } + + @Test + fun `GIVEN BackupDevice1 AND failure other error WHEN onScanClick THEN no dialog is set`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.UserCancelled())) + advanceUntilIdle() + + Assertions.assertNull(model.uiState.value.dialog) + verify { tangemSdkManager.clearProductType() } + } + + @Test + fun `GIVEN BackupDevice1 AND success AND not Finished WHEN onScanClick THEN state moves to BackupDevice2`() = + runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + every { backupService.currentState } returns BackupService.State.FinalizingBackupCard(index = 1) + + mockkConstructor(BackupValidator::class) + every { anyConstructed().isValidBackupStatus(any()) } returns true + + val card: Card = mockk(relaxed = true) + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { + model.onEvent.collect { events.add(it) } + } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Success(card)) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step) + Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber) + Assertions.assertTrue(events.contains(MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded)) + + unmockkConstructor(BackupValidator::class) + } + + private fun String.lastMaskedExpected(): String { + val space = ' ' + val last4 = takeLast(4) + return "$space*$space*$space*$space$last4" + } + + private fun createModel(testScope: TestScope): MultiWalletFinalizeModel { + return MultiWalletFinalizeModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + backupServiceHolder = backupServiceHolder, + tangemSdkManager = tangemSdkManager, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + saveWalletUseCase = saveWalletUseCase, + getUserWalletsUseCase = getUserWalletsUseCase, + updateWalletUseCase = updateWalletUseCase, + syncWalletWithRemoteUseCase = syncWalletWithRemoteUseCase, + cardRepository = cardRepository, + onboardingRepository = onboardingRepository, + walletsRepository = walletsRepository, + uiMessageSender = uiMessageSender, + backupValidator = backupValidator, + analyticsEventHandler = analyticsEventHandler, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private companion object { + private const val NON_RING_BATCH_ID = "AC02" + private const val RING_BATCH_ID_AC17 = "AC17" + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt new file mode 100644 index 0000000000..3b10fcbbbe --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt @@ -0,0 +1,454 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.model + +import com.tangem.common.card.Card +import com.tangem.common.card.FirmwareVersion as SdkFirmwareVersion +import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.ArtworkModel +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onboarding.repository.OnboardingRepository +import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import com.tangem.features.onboarding.v2.TitleProvider +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.title.OnboardingTitle +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.operations.backup.BackupService +import com.tangem.sdk.api.BackupServiceHolder +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +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.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.lang.ref.WeakReference +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +internal class OnboardingMultiWalletModelTest { + + private val analyticsHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) + private val router: Router = mockk(relaxUnitFun = true) + private val backupServiceHolder: BackupServiceHolder = mockk() + private val backupServiceWeakRef: WeakReference = WeakReference(null) + private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true) + private val getCardImageUseCase: GetCardImageUseCase = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val artworkUMConverter: ArtworkUMConverter = mockk() + private val paramsContainer: ParamsContainer = mockk() + private val titleProvider: TitleProvider = mockk(relaxUnitFun = true) + + private val card1Id = "card-id-1" + private val card1PublicKey = byteArrayOf(1, 2, 3) + private val card1ManufacturerName = "Tangem" + private val card1Manufacturer = CardDTO.Manufacturer( + name = card1ManufacturerName, + manufactureDate = Date(0), + signature = null, + ) + private val card1FirmwareVersionDto = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = SdkFirmwareVersion.FirmwareType.Release, + ) + private val card1SdkFirmwareVersion = SdkFirmwareVersion(major = 6, minor = 33) + private val cardDto: CardDTO = mockk() + private val scanResponse: ScanResponse = mockk() + private val artwork1Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-1") + private val artwork1Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-1") + + private val params: OnboardingMultiWalletComponent.Params = mockk { + every { titleProvider } returns this@OnboardingMultiWalletModelTest.titleProvider + every { scanResponse } returns this@OnboardingMultiWalletModelTest.scanResponse + } + + @BeforeEach + fun setUp() { + every { paramsContainer.require() } returns params + every { params.mode } returns OnboardingMultiWalletComponent.Mode.Onboarding + + every { cardDto.cardId } returns card1Id + every { cardDto.cardPublicKey } returns card1PublicKey + every { cardDto.manufacturer } returns card1Manufacturer + every { cardDto.firmwareVersion } returns card1FirmwareVersionDto + every { cardDto.wallets } returns emptyList() + every { cardDto.backupStatus } returns null + + every { scanResponse.card } returns cardDto + every { scanResponse.productType } returns ProductType.Wallet + every { scanResponse.primaryCard } returns null + + every { backupServiceHolder.backupService } returns backupServiceWeakRef + + coEvery { + getCardImageUseCase.invoke( + cardId = card1Id, + cardPublicKey = card1PublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card1ManufacturerName, + firmwareVersion = card1SdkFirmwareVersion, + ) + } returns artwork1Model + every { artworkUMConverter.convert(artwork1Model) } returns artwork1Um + } + + @Test + fun `WHEN model is created THEN OnboardingEvent Started is sent`() = runTest { + createModel(this) + advanceUntilIdle() + + verify { analyticsHandler.send(match { true }) } + } + + @Test + fun `GIVEN UpgradeHotWallet mode WHEN model is created THEN title is common_tangem`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( + userWalletId = UserWalletId("011"), + ) + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.common_tangem)), + ) + } + } + + @Test + fun `GIVEN ContinueFinalize mode WHEN model is created THEN title is finalize_backup`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.ContinueFinalize + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_button_finalize_backup)), + ) + } + } + + @Test + fun `GIVEN AddressSync mode WHEN model is created THEN title is biometrics`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = UserWalletId("011"), + isWalletStarted = false, + ) + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_biometrics)), + ) + } + } + + @Test + fun `GIVEN wallets present AND NoBackup AND no primary card WHEN created THEN title is creating_backup`() = runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup + every { scanResponse.primaryCard } returns null + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_creating_backup)), + ) + } + } + + @Test + fun `GIVEN wallets present AND NoBackup AND Wallet productType WHEN created THEN title is getting_started`() = + runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup + every { scanResponse.primaryCard } returns mockk() + every { scanResponse.productType } returns ProductType.Wallet + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_getting_started)), + ) + } + } + + @Test + fun `GIVEN wallets present AND NoBackup AND non-Wallet productType WHEN created THEN title is creating_backup`() = + runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup + every { scanResponse.primaryCard } returns mockk() + every { scanResponse.productType } returns ProductType.Wallet2 + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_creating_backup)), + ) + } + } + + @Test + fun `GIVEN wallets present AND active backup WHEN created THEN title is finalize_backup`() = runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.Active(cardCount = 2) + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_button_finalize_backup)), + ) + } + } + + @Test + fun `GIVEN no wallets WHEN model is created THEN title is create_wallet_header`() = runTest { + every { cardDto.wallets } returns emptyList() + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_create_wallet_header)), + ) + } + } + + @Test + fun `WHEN model is created THEN loadCardArtwork updates artwork1 in uiState`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + coVerify { + getCardImageUseCase.invoke( + cardId = card1Id, + cardPublicKey = card1PublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card1ManufacturerName, + firmwareVersion = card1SdkFirmwareVersion, + ) + } + verify { artworkUMConverter.convert(artwork1Model) } + Assertions.assertEquals(artwork1Um, model.uiState.value.artwork1) + } + + @Test + fun `GIVEN backups emit card2 WHEN subscribeToBackups THEN artwork2 is loaded and updated`() = runTest { + val card2Info = card2BackupInfo() + val artwork2Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-2") + val artwork2Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-2") + coEvery { + getCardImageUseCase.invoke( + cardId = card2Info.cardId, + cardPublicKey = card2Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card2Info.manufacturer.name, + firmwareVersion = card2Info.firmwareVersion, + ) + } returns artwork2Model + every { artworkUMConverter.convert(artwork2Model) } returns artwork2Um + + val model = createModel(this) + advanceUntilIdle() + + model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info) + advanceUntilIdle() + + coVerify { + getCardImageUseCase.invoke( + cardId = card2Info.cardId, + cardPublicKey = card2Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card2Info.manufacturer.name, + firmwareVersion = card2Info.firmwareVersion, + ) + } + Assertions.assertEquals(artwork2Um, model.uiState.value.artwork2) + } + + @Test + fun `GIVEN backups emit card3 after card2 WHEN subscribeToBackups THEN artwork3 is loaded and updated`() = runTest { + val card2Info = card2BackupInfo() + val card3Info = card3BackupInfo() + val artwork2Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-2") + val artwork2Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-2") + val artwork3Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-3") + val artwork3Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-3") + coEvery { + getCardImageUseCase.invoke( + cardId = card2Info.cardId, + cardPublicKey = card2Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card2Info.manufacturer.name, + firmwareVersion = card2Info.firmwareVersion, + ) + } returns artwork2Model + every { artworkUMConverter.convert(artwork2Model) } returns artwork2Um + coEvery { + getCardImageUseCase.invoke( + cardId = card3Info.cardId, + cardPublicKey = card3Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card3Info.manufacturer.name, + firmwareVersion = card3Info.firmwareVersion, + ) + } returns artwork3Model + every { artworkUMConverter.convert(artwork3Model) } returns artwork3Um + + val model = createModel(this) + advanceUntilIdle() + + model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info) + advanceUntilIdle() + model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info, card3 = card3Info) + advanceUntilIdle() + + coVerify { + getCardImageUseCase.invoke( + cardId = card3Info.cardId, + cardPublicKey = card3Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card3Info.manufacturer.name, + firmwareVersion = card3Info.firmwareVersion, + ) + } + Assertions.assertEquals(artwork3Um, model.uiState.value.artwork3) + } + + @Test + fun `GIVEN non-AddressSync mode WHEN onBack confirmed THEN router pop is called`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.Onboarding + coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs + val dialogSlot = slot() + every { uiMessageSender.send(capture(dialogSlot)) } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.onBack() + dialogSlot.captured.firstAction.onClick.invoke() + advanceUntilIdle() + + coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() } + verify { router.pop(onComplete = any()) } + verify(exactly = 0) { router.popTo(route = any(), onComplete = any()) } + verify(exactly = 0) { router.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN AddressSync mode AND wallet started WHEN onBack confirmed THEN popTo Wallet is called`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = UserWalletId("011"), + isWalletStarted = true, + ) + coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs + val dialogSlot = slot() + every { uiMessageSender.send(capture(dialogSlot)) } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.onBack() + dialogSlot.captured.firstAction.onClick.invoke() + advanceUntilIdle() + + coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() } + verify { router.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { router.pop(onComplete = any()) } + verify(exactly = 0) { router.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN AddressSync mode AND wallet not started WHEN onBack confirmed THEN replaceAll Wallet is called`() = + runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = UserWalletId("011"), + isWalletStarted = false, + ) + coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs + val dialogSlot = slot() + every { uiMessageSender.send(capture(dialogSlot)) } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.onBack() + dialogSlot.captured.firstAction.onClick.invoke() + advanceUntilIdle() + + coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() } + verify { router.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + verify(exactly = 0) { router.pop(onComplete = any()) } + verify(exactly = 0) { router.popTo(route = any(), onComplete = any()) } + } + + private fun card2BackupInfo() = MultiWalletChildParams.Backup.BackupCardInfo( + cardId = "card-id-2", + cardPublicKey = byteArrayOf(4, 5, 6), + manufacturer = Card.Manufacturer(name = "Tangem2", manufactureDate = Date(0), signature = null), + firmwareVersion = SdkFirmwareVersion(major = 6, minor = 34), + ) + + private fun card3BackupInfo() = MultiWalletChildParams.Backup.BackupCardInfo( + cardId = "card-id-3", + cardPublicKey = byteArrayOf(7, 8, 9), + manufacturer = Card.Manufacturer(name = "Tangem3", manufactureDate = Date(0), signature = null), + firmwareVersion = SdkFirmwareVersion(major = 6, minor = 35), + ) + + private fun createModel(testScope: TestScope): OnboardingMultiWalletModel { + return OnboardingMultiWalletModel( + paramsContainer = paramsContainer, + analyticsHandler = analyticsHandler, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + backupServiceHolder = backupServiceHolder, + onboardingRepository = onboardingRepository, + getCardImageUseCase = getCardImageUseCase, + uiMessageSender = uiMessageSender, + artworkUMConverter = artworkUMConverter, + ) + } + + 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/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt new file mode 100644 index 0000000000..8c2e762454 --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt @@ -0,0 +1,66 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.model + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.onboarding.v2.impl.R +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class UtilsTest { + + @ParameterizedTest + @MethodSource("provideScreenTitleByStep") + fun `GIVEN step WHEN screenTitleByStep THEN expected text reference is returned`( + step: OnboardingMultiWalletState.Step, + expected: TextReference, + ) { + val actual = screenTitleByStep(step) + + Assertions.assertEquals(expected, actual) + } + + companion object { + + @JvmStatic + fun provideScreenTitleByStep(): List = listOf( + Arguments.of( + OnboardingMultiWalletState.Step.UpgradeWallet, + resourceReference(R.string.common_tangem), + ), + Arguments.of( + OnboardingMultiWalletState.Step.CreateWallet, + resourceReference(R.string.onboarding_create_wallet_header), + ), + Arguments.of( + OnboardingMultiWalletState.Step.SeedPhrase, + resourceReference(R.string.onboarding_create_wallet_header), + ), + Arguments.of( + OnboardingMultiWalletState.Step.ChooseBackupOption, + resourceReference(R.string.onboarding_getting_started), + ), + Arguments.of( + OnboardingMultiWalletState.Step.ScanPrimary, + resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), + Arguments.of( + OnboardingMultiWalletState.Step.AddBackupDevice, + resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), + Arguments.of( + OnboardingMultiWalletState.Step.AddressSync, + resourceReference(R.string.onboarding_navbar_title_biometrics), + ), + Arguments.of( + OnboardingMultiWalletState.Step.Finalize, + resourceReference(R.string.onboarding_button_finalize_backup), + ), + Arguments.of( + OnboardingMultiWalletState.Step.Done, + resourceReference(R.string.common_done), + ), + ) + } +} \ No newline at end of file diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 4c6b3a90a3..87252298e9 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -12,8 +12,9 @@ android { } dependencies { + /** Project - API */ - implementation(projects.features.account.api) + implementation(projects.features.commonFeatures.api) implementation(projects.features.onramp.api) implementation(projects.features.swap.api) implementation(projects.features.swap.domain) @@ -81,5 +82,4 @@ dependencies { /** Other */ implementation(deps.decompose.ext.compose) implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt index 584aadf822..11a144fc73 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt @@ -2,10 +2,11 @@ package com.tangem.features.onramp.deeplink import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.utils.logging.TangemLogger import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import com.tangem.utils.logging.TangemLogger internal class DefaultSwapDeepLinkHandler @AssistedInject constructor( router: AppRouter, @@ -19,7 +20,12 @@ internal class DefaultSwapDeepLinkHandler @AssistedInject constructor( TangemLogger.e("Error on getting user wallet: $it") }, ifRight = { userWallet -> - router.push(AppRoute.SwapCrypto(userWallet.walletId)) + router.push( + AppRoute.Swap( + userWalletId = userWallet.walletId, + screenSource = AnalyticsParam.ScreensSources.Main.value, + ), + ) }, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt index 1112152d60..50fb0e3040 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt @@ -31,7 +31,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.onramp.hottokens.model.HotCryptoModel import com.tangem.features.onramp.hottokens.portfolio.OnrampAddToPortfolioComponent import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt index a9e87d2738..557dd5b1d9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.onramp.hottokens.converter -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter import com.tangem.core.ui.components.token.state.TokenItemState diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt index cf1badae14..e25061f64d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt @@ -22,8 +22,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.onramp.model.HotCryptoCurrency -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.onramp.hottokens.HotCryptoComponent import com.tangem.features.onramp.hottokens.converter.HotTokenItemStateConverter import com.tangem.features.onramp.hottokens.entity.HotCryptoUM diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt index fe12663f1f..a8408116b9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt @@ -7,10 +7,10 @@ import com.tangem.common.ui.account.toUM import com.tangem.common.ui.addtoken.AddTokenUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt index 0432278b80..ea195b5280 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt @@ -50,7 +50,7 @@ internal class OnrampAddTokenModel @Inject constructor( .distinctUntilChanged() .mapLatest { tokenToAdd: AddHotCryptoData -> addTokenJob.join() - val backendId = tokenToAdd.cryptoCurrency.network.backendId + val backendId = tokenToAdd.cryptoCurrency.network.rawId val userWalletId = tokenToAdd.account.accountId.userWalletId val isTangemIconVisible = needColdWalletInteraction( walletId = userWalletId, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt index ee2dc2dd5b..d18755aaf4 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt @@ -15,7 +15,7 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.onramp.component.SwapSelectTokensComponent import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute @@ -74,9 +74,7 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( return addToPortfolioComponentFactory.create( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( - addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager!!, - callback = selectToTokenListComponent.addToPortfolioCallback, - shouldSkipTokenActionsScreen = true, + addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt index bae0b5ba51..2292f1bdf8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -7,8 +7,7 @@ import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.tokenlist.entity.TokenListUM import kotlinx.coroutines.flow.StateFlow @@ -18,8 +17,7 @@ import kotlinx.coroutines.flow.StateFlow internal interface AvailableSwapPairsComponent : ComposableListContentComponent { val bottomSheetNavigation: SlotNavigation - val addToPortfolioManager: AddToPortfolioManager? - val addToPortfolioCallback: AddToPortfolioComponent.Callback + val addToPortfolioManager: AddToPortfolioManager /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index 27e528cd04..347d4ad933 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -6,8 +6,7 @@ import androidx.compose.ui.Modifier import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel import com.tangem.features.onramp.tokenlist.entity.TokenListUM @@ -26,8 +25,7 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( private val model: AvailableSwapPairsModel = getOrCreateModel(params) override val bottomSheetNavigation: SlotNavigation get() = model.bottomSheetNavigation - override val addToPortfolioManager: AddToPortfolioManager? get() = model.addToPortfolioManager - override val addToPortfolioCallback: AddToPortfolioComponent.Callback get() = model.addToPortfolioCallback + override val addToPortfolioManager: AddToPortfolioManager get() = model.addToPortfolioManager override val uiState: StateFlow get() = model.state diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index ed3d347418..d87b8d3d0e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -41,8 +41,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.domain.GetAvailablePairsUseCase import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer @@ -62,13 +61,10 @@ import com.tangem.features.onramp.utils.ClearSearchBarTransformer import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.runSuspendCatching -import com.tangem.utils.coroutines.saveIn import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -93,7 +89,6 @@ internal class AvailableSwapPairsModel @Inject constructor( private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val excludedBlockchains: ExcludedBlockchains, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - swapFeatureToggles: SwapFeatureToggles, getWalletsUseCase: GetWalletsUseCase, ) : Model() { @@ -104,14 +99,12 @@ internal class AvailableSwapPairsModel @Inject constructor( private val allUserWallets = getWalletsUseCase.invokeSync() val bottomSheetNavigation: SlotNavigation = SlotNavigation() - var addToPortfolioManager: AddToPortfolioManager? = null - val addToPortfolioCallback: AddToPortfolioComponent.Callback = object : AddToPortfolioComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency) { - onTokenAddedToPortfolio(addedToken) - } - } - private val addToPortfolioJobHolder = JobHolder() + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory + .create( + scope = modelScope, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = ScreensSources.Swap.value), + settings = AddToPortfolioManager.Settings.ChooseToken, + ) private val accountListFlow = getAccountListUseCaseFlow() private val availablePairsByNetworkFlow = MutableStateFlow>(emptyMap()) @@ -155,10 +148,14 @@ internal class AvailableSwapPairsModel @Inject constructor( subscribeOnSelectedStatusChange() subscribeOnAvailablePairsUpdates() - if (swapFeatureToggles.isMarketListFeatureEnabled) { - subscribeOnMarketsUpdates() - subscribeOnVisibleMarketItems() - } + subscribeOnMarketsUpdates() + subscribeOnVisibleMarketItems() + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { result -> onTokenAddedToPortfolio(result.addedCurrency.currency) } + .launchIn(modelScope) } private fun getAccountListUseCaseFlow(): SharedFlow> { @@ -251,7 +248,7 @@ internal class AvailableSwapPairsModel @Inject constructor( .associate { accountStatus -> val statuses = accountStatus.tokenList.flattenCurrencies() .filterNot { status -> - status.currency.network.backendId == selectedStatus?.currency?.network?.backendId && + status.currency.network.rawId == selectedStatus?.currency?.network?.rawId && status.currency.id.contractAddress == selectedStatus.currency.id.contractAddress } .filterByQuery(query = query) @@ -458,7 +455,7 @@ internal class AvailableSwapPairsModel @Inject constructor( private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo { return LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.backendId, + network = currency.network.rawId, ) } @@ -593,45 +590,35 @@ internal class AvailableSwapPairsModel @Inject constructor( } private fun addToPortfolioItem(item: MarketsListItemUM) { - modelScope.launch { - val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) - ?: searchMarketsListManager.getTokenMarketById(item.id) - ?: return@launch + val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) + ?: searchMarketsListManager.getTokenMarketById(item.id) + ?: return - val param = tokenMarket.toSerializableParam() - val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } + val param = tokenMarket.toSerializableParam() + val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } - val networks = tokenMarket.networks?.filter { network -> - BlockchainUtils.isSupportedNetworkId( - blockchainId = network.networkId, - coinId = tokenMarket.id.value, - contractAddress = network.contractAddress, - excludedBlockchains = excludedBlockchains, - hotExcludedBlockchains = hotWalletExcludedBlockchains, - hasOnlyHotWallets = hasOnlyHotWallets, - ) - }?.map { network -> - TokenMarketInfo.Network( - networkId = network.networkId, - isExchangeable = false, - contractAddress = network.contractAddress, - decimalCount = network.decimalCount, - ) - }.orEmpty() + val networks = tokenMarket.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + networkId = network.networkId, + coinId = tokenMarket.id.value, + contractAddress = network.contractAddress, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }?.map { network -> + TokenMarketInfo.Network( + networkId = network.networkId, + isExchangeable = false, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + }.orEmpty() - addToPortfolioManager = addToPortfolioManagerFactory - .create( - scope = modelScope, - token = param, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = ScreensSources.Swap.value), - ).apply { - setTokenNetworks(networks) - } + addToPortfolioManager.setTokenNetworks(networks) + addToPortfolioManager.setTokenParams(param) - addToPortfolioManager?.state - ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } - ?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) } - }.saveIn(addToPortfolioJobHolder) + bottomSheetNavigation.activate(AddToPortfolioRoute) } private fun subscribeOnVisibleMarketItems() { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index cf05eb89cc..876a6aa1dc 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -114,8 +114,7 @@ internal class SwapSelectTokensModel @Inject constructor( router.push( route = AppRoute.Swap( - currencyFrom = requireNotNull(fromCurrencyStatus.value).currency, - currencyTo = status.currency, + cryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency, userWalletId = params.userWalletId, screenSource = AnalyticsParam.ScreensSources.Main.value, ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt index 7939ad13ad..f1e42dc86e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt @@ -4,7 +4,7 @@ import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index 884058c33b..b11f85dce7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -309,10 +309,7 @@ internal class OnrampTokenListModel @Inject constructor( ).isRight() } OnrampOperation.SWAP -> { - val isAvailable = rampStateManager.availableForSwap( - userWalletId = params.userWalletId, - cryptoCurrency = status.currency, - ).isAvailable() && !status.currency.isCustom + val isAvailable = !status.currency.isCustom val supplyStatus = status.value.yieldSupplyStatus val isUnavailableByYieldSupply = supplyStatus?.isAllowedToSpend == false && supplyStatus.isActive diff --git a/features/referral/impl/build.gradle.kts b/features/referral/impl/build.gradle.kts index 8f23358f8f..626b3f81d3 100644 --- a/features/referral/impl/build.gradle.kts +++ b/features/referral/impl/build.gradle.kts @@ -13,7 +13,7 @@ android { dependencies { /** Api */ api(projects.features.referral.api) - api(projects.features.account.api) + api(projects.features.commonFeatures.api) /** Core modules */ implementation(projects.core.analytics) diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt index d85d5291f1..fba435fa4c 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt @@ -13,16 +13,16 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.feature.referral.api.ReferralComponent import com.tangem.feature.referral.model.ReferralModel import com.tangem.feature.referral.ui.ReferralScreen -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.serialization.builtins.serializer class DefaultReferralComponent @AssistedInject constructor( - private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, @Assisted appComponentContext: AppComponentContext, @Assisted params: ReferralComponent.Params, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, ) : ReferralComponent, AppComponentContext by appComponentContext { private val model: ReferralModel = getOrCreateModel(params) diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt index cc1e154773..71681de87f 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt index f928bf5249..6bf34b1050 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt @@ -37,9 +37,9 @@ import com.tangem.feature.referral.domain.models.ReferralInfo import com.tangem.feature.referral.models.DemoModeException import com.tangem.feature.referral.models.ReferralStateHolder import com.tangem.feature.referral.models.ReferralStateHolder.* -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* @@ -53,14 +53,14 @@ import javax.inject.Inject internal class ReferralModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + val portfolioSelectorController: PortfolioSelectorController, + private val portfolioFetcherFactory: PortfolioFetcher.Factory, private val referralInteractor: ReferralInteractor, private val analyticsEventHandler: AnalyticsEventHandler, private val shareManager: ShareManager, private val urlOpener: UrlOpener, private val getUserWalletUseCase: GetUserWalletUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, - private val portfolioFetcherFactory: PortfolioFetcher.Factory, - val portfolioSelectorController: PortfolioSelectorController, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt index e084555a7b..9e1f4c23df 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -133,7 +133,6 @@ private fun Preview() { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index 5280350577..366e500c80 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -516,7 +516,6 @@ private val cryptoCurrencyStatus value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt index 76adedfcaf..7a57435e0e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt @@ -171,7 +171,6 @@ private fun Preview() { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index a74fac4f79..80548cd189 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -258,7 +258,6 @@ private class FeeSelectorUMProvider : PreviewParameterProvider { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt index 3a48260b02..8115f88043 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt @@ -11,7 +11,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.common.ui.account.AccountIconItemStateConverter import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.CurrencyIconStateBuilder +import com.tangem.common.ui.components.currency.icon.CurrencyIconStateBuilder import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.resourceReference diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index 4c9a5154ca..1a5ca89baa 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -21,7 +21,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -74,10 +73,10 @@ import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceTrigger import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.stripZeroPlainString +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal import javax.inject.Inject import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned @@ -114,7 +113,6 @@ internal class SendConfirmModel @Inject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val currenciesRepository: CurrenciesRepository, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -281,8 +279,7 @@ internal class SendConfirmModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = confirmData.enteredDestination.orEmpty(), tokenSymbol = if (amount?.type is AmountType.Token) { amount.currencySymbol @@ -587,8 +584,7 @@ internal class SendConfirmModel @Inject constructor( val confirmUM = uiState.value.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending - val isHoldToConfirm = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - userWallet.isHotWallet && isContent + val isHoldToConfirm = userWallet.isHotWallet && isContent return NavigationButton( textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), iconRes = walletInterationIcon(userWallet), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 9b4c0a31fa..a4be5674aa 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -18,6 +18,7 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -33,7 +34,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase @@ -65,9 +65,9 @@ import com.tangem.features.send.v2.subcomponents.destination.model.transformers. import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject import kotlin.properties.Delegates @@ -528,8 +528,7 @@ internal class SendModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = "", tokenSymbol = "", amount = "", diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index b15638b859..a707e8b5c1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -16,17 +16,16 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.wallet.isHotWallet import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase @@ -57,10 +56,10 @@ import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendCon import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.stripZeroPlainString +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal import javax.inject.Inject import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned @@ -91,7 +90,6 @@ internal class NFTSendConfirmModel @Inject constructor( private val nftSendAnalyticHelper: NFTSendAnalyticHelper, private val nftSendSuccessTrigger: NFTSendSuccessTrigger, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), NFTSendConfirmClickIntents, SendNotificationsComponent.ModelCallback, FeeSelectorModelCallback { @@ -197,8 +195,7 @@ internal class NFTSendConfirmModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = confirmData.enteredDestination.orEmpty(), tokenSymbol = null, amount = params.nftAsset.amount?.toString().orEmpty(), @@ -379,7 +376,7 @@ internal class NFTSendConfirmModel @Inject constructor( flow = uiState, flow2 = params.currentRoute, transform = { state, route -> state to route }, - ).onEach { (state, route) -> + ).onEach { (state, _) -> val confirmUM = state.confirmUM params.callback.onResult( state.copy( @@ -425,8 +422,7 @@ internal class NFTSendConfirmModel @Inject constructor( val confirmUM = uiState.value.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending - val isHoldToConfirm = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - userWallet.isHotWallet && isContent + val isHoldToConfirm = userWallet.isHotWallet && isContent return NavigationButton( textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), iconRes = walletInterationIcon(userWallet), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt index a0b35c9df4..3d7216fc41 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -15,6 +15,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -27,7 +28,6 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.transaction.error.GetFeeError @@ -223,8 +223,7 @@ internal class NFTSendModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = "", tokenSymbol = null, amount = "", diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index bb7eccfa3b..7057fd13d9 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -15,18 +15,16 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase -import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.v2.api.entity.PredefinedValues @@ -44,7 +42,6 @@ import com.tangem.utils.extensions.orZero import com.tangem.utils.isNullOrZero import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.properties.Delegates @@ -61,7 +58,6 @@ internal class SendAmountModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getUserWalletUseCase: GetUserWalletUseCase, - private val rampStateManager: RampStateManager, private val sendAmountAlertFactory: SendAmountAlertFactory, private val getWalletsUseCase: GetWalletsUseCase, ) : Model(), SendAmountClickIntents { @@ -73,7 +69,6 @@ internal class SendAmountModel @Inject constructor( private val _uiState = MutableStateFlow(params.state) val uiState = _uiState.asStateFlow() - private var isAvailableForSwap: Boolean = false val isSendWithSwapAvailable: StateFlow field = MutableStateFlow(false) @@ -87,12 +82,6 @@ internal class SendAmountModel @Inject constructor( private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull() init { - modelScope.launch { - isAvailableForSwap = rampStateManager.availableForSwap( - userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, - ) == ScenarioUnavailabilityReason.None - } configAmountNavigation() initAppCurrency() subscribeOnCryptoCurrencyStatusFlow() @@ -409,7 +398,7 @@ internal class SendAmountModel @Inject constructor( val isMultiCurrency = userWallet?.isMultiCurrency == true isSendWithSwapAvailable.update { - isAvailableForSwap && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr + !params.cryptoCurrency.isCustom && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr } } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 293f18bdc0..e2cc71268c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -19,7 +19,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked -import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase @@ -69,7 +68,6 @@ internal class SendDestinationModel @Inject constructor( private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val parseQrCodeUseCase: ParseQrCodeUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory, private val analyticsEventHandler: AnalyticsEventHandler, private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, ) : Model(), SendDestinationClickIntents { @@ -262,16 +260,15 @@ internal class SendDestinationModel @Inject constructor( private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? { val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null - val address = when (val status = this.value) { - is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress - is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress + val (paymentAccountAddress, currency) = when (val status = this.value) { + is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency else -> return null } - val currency = tangemPayCryptoCurrencyFactory.create(wallet).getOrNull() ?: return null + return if (contractAddress.equals(currency.contractAddress, true)) { DestinationWalletUM( name = wallet.name, - address = address, + address = paymentAccountAddress, cryptoCurrency = currency, userWalletId = wallet.walletId, account = account, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 676eb712c2..5e0111ec67 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -253,7 +253,7 @@ internal class NotificationsModel @Inject constructor( addExceedsBalanceNotification( cryptoCurrencyWarning = currencyWarning, cryptoCurrencyStatus = cryptoCurrencyStatus, - shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(currency.network.backendId), + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(currency.network.rawId), onClick = ::showTokenDetails, onAnalyticsEvent = { val event = NotificationsAnalyticEvents.NoticeNotEnoughFee( diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt index 92dc0a0d66..c338f3ca59 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt @@ -30,7 +30,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" - val cryptoCurrencyStatus = CryptoCurrencyStatus( + private val cryptoCurrencyStatus = CryptoCurrencyStatus( currency = CryptoCurrency.Coin( id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), network = Network( @@ -38,7 +38,6 @@ class NFTSendConfirmationNotificationsTransformerV2Test { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index ee1d34d98e..aa166774b7 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -32,7 +32,7 @@ class SendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" - val cryptoCurrencyStatus = CryptoCurrencyStatus( + private val cryptoCurrencyStatus = CryptoCurrencyStatus( currency = CryptoCurrency.Coin( id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), network = Network( @@ -40,7 +40,6 @@ class SendConfirmationNotificationsTransformerV2Test { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index f2d9d8b1cf..218c739822 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -10,6 +10,10 @@ android { namespace = "com.tangem.features.staking.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** AndroidX */ implementation(deps.androidx.fragment.ktx) @@ -88,4 +92,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/staking/impl/detekt-baseline-debug.xml b/features/staking/impl/detekt-baseline-debug.xml deleted file mode 100644 index 8cd2dca55b..0000000000 --- a/features/staking/impl/detekt-baseline-debug.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - BooleanPropertyNaming:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer$val showNotification = sendingAmount + feeAmount > balance - BooleanPropertyNaming:AmountCurrencyChangeStateTransformer.kt$AmountCurrencyChangeStateTransformer$private val value: Boolean - BooleanPropertyNaming:StakingUiState.kt$StakingStates.InitialInfoState.Data$val showBanner: Boolean - BooleanPropertyNaming:StakingUiState.kt$StakingUiState$val showColdWalletInteractionIcon: Boolean - CastNullableToNonNullableType:SetApprovalBottomSheetInProgressTransformer.kt$SetApprovalBottomSheetInProgressTransformer$as - CastNullableToNonNullableType:SetApprovalBottomSheetTypeChangeTransformer.kt$SetApprovalBottomSheetTypeChangeTransformer$as - MultilineLambdaItParameter:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer${ it is StakingNotification.Error || it is NotificationUM.Error || it is NotificationUM.Warning.NetworkFeeUnreachable || it is StakingNotification.Warning.TransactionInProgress || it is StakingNotification.Warning.InitializeTonAccount } - MultilineLambdaItParameter:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler${ val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } - MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.body1, ) } } - MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) } } - MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.PREPARING || it.type == BalanceType.STAKED || it.type == BalanceType.LOCKED } - MultilineLambdaItParameter:StakingStateController.kt$StakingStateController${ it.copy( showColdWalletInteractionIcon = userWallet is UserWallet.Cold, ) } - NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$networkId - NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$tokenId - PropertyUsedBeforeDeclaration:StakingFeeBlock.kt$FeeBlockPreviewProvider$contentState - PropertyUsedBeforeDeclaration:StakingStateController.kt$StakingStateController$uiState - UnnecessaryEventHandlerParameter:StakingInitialInfoContent.kt$onClick: (BalanceState) -> Unit - UnnecessaryLet:StakingTosText.kt$let { onTextClick(PRIVACY_POLICY_URL) } - UnnecessaryLet:StakingTosText.kt$let { onTextClick(TERMS_OF_USE_URL) } - - diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index c861dfb887..648df734f4 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -53,9 +53,9 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId), ) .orEmpty() - .firstOrNull { - val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) - val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + .firstOrNull { currency -> + val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true) + val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } @@ -63,8 +63,8 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( TangemLogger.e( """ Could not get crypto currency for - |- $NETWORK_ID_KEY: $networkId - |- $TOKEN_ID_KEY: $tokenId + |- $NETWORK_ID_KEY: ${networkId.orEmpty()} + |- $TOKEN_ID_KEY: ${tokenId.orEmpty()} """.trimIndent(), ) return@launch diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt index 66b3ccedc6..9ae89b73e3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.model +import androidx.compose.runtime.Immutable import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.notifications.NotificationUM @@ -11,6 +12,7 @@ import java.math.BigDecimal // TODO split this interface to click intents and other interaction events @Suppress("TooManyFunctions") +@Immutable internal interface StakingClickIntents : AmountScreenClickIntents { fun onBackClick() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 34b0549f39..4928b3356f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -2,21 +2,20 @@ package com.tangem.features.staking.impl.presentation.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.getValidatorsCount import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate -import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig -import com.tangem.features.approval.api.GiveApprovalComponent -import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.ParamsInterceptorHolder @@ -36,11 +35,11 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase @@ -61,12 +60,13 @@ import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.P2PEthPoolRepository -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.features.staking.api.StakingComponent import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.analytics.StakingParamsInterceptor @@ -96,21 +96,19 @@ import com.tangem.features.staking.impl.presentation.state.utils.isSingleAction import com.tangem.features.staking.impl.presentation.state.utils.withStubUnstakeAction import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider -import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.isSingleItem import com.tangem.utils.extensions.orZero +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject -import kotlin.properties.Delegates @Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @Stable @@ -122,7 +120,7 @@ internal class StakingModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @@ -205,7 +203,13 @@ internal class StakingModel @Inject constructor( getUserWalletUseCase(userWalletId).getOrNull(), ) { "No wallet found for id: $userWalletId" } } - private var appCurrency: AppCurrency by Delegates.notNull() + private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) private val balancesToShow: List get() { @@ -310,7 +314,6 @@ internal class StakingModel @Inject constructor( private val balanceHidingJobHolder = JobHolder() init { - subscribeOnSelectedAppCurrency() subscribeOnCurrencyStatusUpdates() stateController.initializeWithUserWallet(userWallet) } @@ -364,7 +367,7 @@ internal class StakingModel @Inject constructor( clickIntents = this@StakingModel, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, isBalanceHidden = isBalanceHiddenFlow.value, isAccountsModeEnabled = isAccountsModeEnabled, account = account, @@ -398,7 +401,7 @@ internal class StakingModel @Inject constructor( stateController.update( SetConfirmationStateLoadingTransformer( integration = integration, - appCurrency = appCurrency, + appCurrency = currentAppCurrency.value, cryptoCurrency = cryptoCurrencyStatus.currency, ), ) @@ -407,7 +410,7 @@ internal class StakingModel @Inject constructor( onStakingFee = { gasEstimate, isFeeApproximate -> stateController.update( SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = gasEstimate, isFeeApproximate = isFeeApproximate, @@ -432,7 +435,7 @@ internal class StakingModel @Inject constructor( onApprovalFee = { fee -> stateController.update( SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = fee, cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -450,10 +453,9 @@ internal class StakingModel @Inject constructor( stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value) stateController.update(SetConfirmationStateInProgressTransformer()) - if (integration is P2PEthPoolIntegration) { - checkFeeAndSendP2PTransaction() - } else { - sendTransaction() + when (integration) { + is P2PEthPoolIntegration -> checkFeeAndSendP2PTransaction() + is StakeKitIntegration -> sendTransaction() } }.saveIn(sendTransactionJobHolder) } @@ -543,7 +545,7 @@ internal class StakingModel @Inject constructor( stateController.updateAll( SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = increasedFee, isFeeApproximate = isFeeApproximate, @@ -601,7 +603,9 @@ internal class StakingModel @Inject constructor( override fun onInitialInfoBannerClick() { analyticsEventHandler.send(StakingAnalyticsEvent.WhatIsStaking()) - innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL) + modelScope.launch { + innerRouter.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowToStake)) + } } override fun onInfoClick(infoType: InfoType) { @@ -791,7 +795,7 @@ internal class StakingModel @Inject constructor( stateController.update( ShowApprovalBottomSheetTransformer( userWallet = userWallet, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) { @@ -843,7 +847,7 @@ internal class StakingModel @Inject constructor( ) stateController.update( SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = TransactionFee.Single(fee), cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -872,7 +876,7 @@ internal class StakingModel @Inject constructor( ) stateController.update( SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = TransactionFee.Single(fee), cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -930,7 +934,7 @@ internal class StakingModel @Inject constructor( stateController.update( AddStakingNotificationsTransformer( cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, isAccountInitializedProvider = Provider { isAccountInitialized }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, currencyWarning = currencyWarning, @@ -949,7 +953,7 @@ internal class StakingModel @Inject constructor( reduceAmountByDiff: BigDecimal, notification: Class, ) { - AmountReduceByStateTransformer( + val transformer = AmountReduceByStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, value = ReduceByData( @@ -957,6 +961,7 @@ internal class StakingModel @Inject constructor( reduceAmountByDiff = reduceAmountByDiff, ), ) + stateController.update(transformer) onNotificationCancel(notification) } @@ -1054,8 +1059,9 @@ internal class StakingModel @Inject constructor( modelScope.launch { val network = cryptoCurrencyStatus.currency.network - val metaInfo = - getWalletMetaInfoUseCase(userWallet.walletId).getOrElse { error("CardInfo must be not null") } + val metaInfo = getWalletMetaInfoUseCase(userWalletId = userWallet.walletId).getOrElse { + error("CardInfo must be not null") + } val amountState = uiState.value.amountState as? AmountState.Data val confirmationState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data val validatorState = uiState.value.validatorState as? StakingStates.ValidatorState.Data @@ -1067,8 +1073,7 @@ internal class StakingModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = network.rawId, - derivationPath = network.derivationPath.value, + networkId = network.id, destinationAddress = target?.address.orEmpty(), tokenSymbol = (cryptoCurrencyStatus.currency as? CryptoCurrency.Token)?.symbol, amount = amount?.run { value?.toPlainString() + currencySymbol }.orEmpty(), @@ -1097,7 +1102,9 @@ internal class StakingModel @Inject constructor( } override fun onOpenLearnMoreAboutApproveClick() { - urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission)) + } } override fun onActivateTonAccountNotificationClick() { @@ -1151,7 +1158,7 @@ internal class StakingModel @Inject constructor( ifRight = { fee -> stateController.update( SetFeeToTonInitializeBottomSheetTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = fee.normal, isFeeApproximate = false, @@ -1306,7 +1313,7 @@ internal class StakingModel @Inject constructor( transformer = HideBalanceStateTransformer( isBalanceHidden = settings.isBalanceHidden, cryptoCurrencyStatus = cryptoCurrencyStatus, - appCurrency = appCurrency, + appCurrency = currentAppCurrency.value, ), ) } @@ -1315,17 +1322,6 @@ internal class StakingModel @Inject constructor( .saveIn(balanceHidingJobHolder) } - private fun subscribeOnSelectedAppCurrency() { - getSelectedAppCurrencyUseCase() - .conflate() - .distinctUntilChanged() - .onEach { maybeAppCurrency -> - appCurrency = maybeAppCurrency.getOrElse { AppCurrency.Default } - } - .flowOn(dispatchers.main) - .launchIn(modelScope) - } - private fun subscribeOnStepChanges(status: CryptoCurrencyStatus) { uiState .distinctUntilChangedBy { it.currentStep } @@ -1382,7 +1378,7 @@ internal class StakingModel @Inject constructor( isAnyTokenStaked = isAnyTokenStaked, cryptoCurrencyStatus = status, userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, balancesToShowProvider = Provider { balancesToShow }, isAccountsModeEnabled = isAccountsModeEnabled, account = account, @@ -1420,7 +1416,7 @@ internal class StakingModel @Inject constructor( clickIntents = this, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, isAccountsModeEnabled = isAccountsModeEnabled, isBalanceHidden = isBalanceHiddenFlow.value, account = account, @@ -1504,7 +1500,6 @@ internal class StakingModel @Inject constructor( } private companion object { - const val WHAT_IS_STAKING_ARTICLE_URL = "https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/" const val ALLOWANCE_UPDATE_DELAY = 10_000L } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index 7a02bd36ef..3e77d49d36 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isColdWallet import com.tangem.domain.models.wallet.isHotWallet import javax.inject.Inject @@ -23,24 +22,22 @@ import javax.inject.Inject @ModelScoped internal class StakingStateController @Inject constructor( urlOpener: UrlOpener, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) { - val value: StakingUiState get() = uiState.value - private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) val uiState: StateFlow get() = mutableUiState.asStateFlow() + val value: StakingUiState get() = uiState.value + private val buttonsTransformer = SetButtonsStateTransformer(urlOpener) private val titleTransformer = SetTitleTransformer fun initializeWithUserWallet(userWallet: UserWallet) { mutableUiState.update { state -> state.copy( - showColdWalletInteractionIcon = userWallet.isColdWallet, - shouldShowHoldToConfirmButton = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - userWallet.isHotWallet, + isColdWalletInteractionIconVisible = userWallet.isColdWallet, + shouldShowHoldToConfirmButton = userWallet.isHotWallet, ) } } @@ -89,7 +86,7 @@ internal class StakingStateController @Inject constructor( actionType = StakingActionCommonType.Enter(skipEnterAmount = false), buttonsState = NavigationButtonsState.Empty, balanceState = null, - showColdWalletInteractionIcon = true, + isColdWalletInteractionIconVisible = true, shouldShowHoldToConfirmButton = false, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index 8624ce9a72..f9a8b1cf23 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -39,22 +39,9 @@ internal data class StakingUiState( val actionType: StakingActionCommonType, val buttonsState: NavigationButtonsState, val balanceState: BalanceState?, - val showColdWalletInteractionIcon: Boolean, + val isColdWalletInteractionIconVisible: Boolean, val shouldShowHoldToConfirmButton: Boolean, -) { - - fun copyWrapped( - initialInfoState: StakingStates.InitialInfoState = this.initialInfoState, - amountState: AmountState = this.amountState, - confirmationState: StakingStates.ConfirmationState = this.confirmationState, - validatorState: StakingStates.ValidatorState = this.validatorState, - ): StakingUiState = copy( - initialInfoState = initialInfoState, - amountState = amountState, - confirmationState = confirmationState, - validatorState = validatorState, - ) -} +) internal sealed class StakingStates { @@ -64,7 +51,7 @@ internal sealed class StakingStates { sealed class InitialInfoState : StakingStates() { data class Data( override val isPrimaryButtonEnabled: Boolean, - val showBanner: Boolean, + val isBannerVisible: Boolean, val infoItems: ImmutableList, val onInfoClick: (InfoType) -> Unit, val yieldBalance: InnerYieldBalanceState, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt index a46b3a7c79..2cee0bc82d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -85,7 +85,7 @@ internal class StakingBalanceEntryConverter( private fun StakingBalanceEntry.getBalanceValue(): BigDecimal { val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( - blockchainId = cryptoCurrencyStatus.currency.network.rawId, + networkId = cryptoCurrencyStatus.currency.network.rawId, ) return if (isIncludeStakingTotalBalance) { amount diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt index ca9f4e5992..8e16ba2154 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt @@ -33,13 +33,13 @@ import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions import com.tangem.utils.extensions.orZero +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal @Suppress("LongParameterList") @@ -165,7 +165,7 @@ internal class StakeKitTransactionSender @AssistedInject constructor( ?.map { transaction -> async { getConstructedStakingTransactionUseCase( - networkId = cryptoCurrencyStatus.currency.network.rawId, + networkId = cryptoCurrencyStatus.currency.network.id.rawId, fee = fee, amount = amount.convertToSdkAmount(cryptoCurrencyStatus), transactionId = transaction.id, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index c54d001d68..ba083ad159 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -18,7 +18,7 @@ import kotlinx.collections.immutable.persistentListOf internal object InitialStakingStatePreview { val defaultState = StakingStates.InitialInfoState.Data( isPrimaryButtonEnabled = true, - showBanner = true, + isBannerVisible = true, infoItems = persistentListOf( RoundedListWithDividersItemData( id = R.string.staking_details_available, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt index 8a299e98ef..43b6a85abb 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt @@ -5,7 +5,7 @@ import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt index 725c5869e8..b67b009796 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -37,7 +37,7 @@ internal class SetButtonsStateTransformer( return prevState.copy(buttonsState = buttonsState) } - private fun getPrimaryButton(prevState: StakingUiState): NavigationButton? { + private fun getPrimaryButton(prevState: StakingUiState): NavigationButton { val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data val innerConfirmState = confirmState?.innerState @@ -52,7 +52,7 @@ internal class SetButtonsStateTransformer( val isPrimaryButtonDisabled = prevState.isPrimaryButtonDisabled() return NavigationButton( textReference = prevState.getButtonText(), - iconRes = R.drawable.ic_tangem_24.takeIf { prevState.showColdWalletInteractionIcon }, + iconRes = R.drawable.ic_tangem_24.takeIf { prevState.isColdWalletInteractionIconVisible }, isDimmed = isPrimaryButtonDisabled, isIconVisible = isIconVisible, shouldShowProgress = isInProgress, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 1dbb49e096..97e0137e20 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -8,7 +8,7 @@ import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.crypto @@ -96,7 +96,7 @@ internal class SetInitialDataStateTransformer( isPrimaryButtonEnabled = with(status) { !amount.isNullOrZero() && sources.stakingBalanceSource.isActual() && sources.networkSource.isActual() }, - showBanner = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty, + isBannerVisible = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty, infoItems = getInfoItems(), onInfoClick = clickIntents::onInfoClick, yieldBalance = yieldBalance, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt index 9631543416..5c121f20e1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt @@ -7,11 +7,11 @@ import com.tangem.utils.transformer.Transformer internal class AmountCurrencyChangeStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, - private val value: Boolean, + private val isFiatValue: Boolean, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( - amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState), + amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, isFiatValue).transform(prevState.amountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt index 5221aba998..997a7e6743 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt @@ -27,7 +27,7 @@ internal class SetApprovalBottomSheetInProgressTransformer( ), onCancel = onDismiss, ) - } as TangemBottomSheetConfigContent, + } as? TangemBottomSheetConfigContent ?: return prevState, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt index 0e58d0c7eb..099b77c51a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt @@ -16,7 +16,7 @@ internal class SetApprovalBottomSheetTypeChangeTransformer( bottomSheetConfig = prevState.bottomSheetConfig?.copy( content = approvalBottomSheetConfig?.copy( data = approvalBottomSheetConfig.data.copy(approveType = approveType), - ) as TangemBottomSheetConfigContent, + ) as? TangemBottomSheetConfigContent ?: return prevState, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt index 7bb3f0630d..f4faa5db34 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -10,7 +10,7 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachable import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency @@ -177,12 +177,12 @@ internal class AddStakingNotificationsTransformer( } private fun isPrimaryButtonEnabled(notifications: ImmutableList, isActualSources: Boolean) = - notifications.none { - it is StakingNotification.Error || - it is NotificationUM.Error || - it is NotificationUM.Warning.NetworkFeeUnreachable || - it is StakingNotification.Warning.TransactionInProgress || - it is StakingNotification.Warning.InitializeTonAccount + notifications.none { notification -> + notification is StakingNotification.Error || + notification is NotificationUM.Error || + notification is NotificationUM.Warning.NetworkFeeUnreachable || + notification is StakingNotification.Warning.TransactionInProgress || + notification is StakingNotification.Warning.InitializeTonAccount } && isActualSources private fun MutableList.addStakingErrorNotifications( @@ -229,7 +229,7 @@ internal class AddStakingNotificationsTransformer( addExceedsBalanceNotification( cryptoCurrencyWarning = currencyWarning, cryptoCurrencyStatus = cryptoCurrencyStatus, - shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.rawId), onClick = prevState.clickIntents::openTokenDetails, onAnalyticsEvent = { prevState.clickIntents.onNotEnoughFeeNotificationShow() }, onResetAnalyticsEvent = { /*no-op*/ }, @@ -302,8 +302,8 @@ internal class AddStakingNotificationsTransformer( val balance = cryptoCurrencyStatus.value.amount.orZero() if (!isSubtractionAvailable) return - val showNotification = sendingAmount + feeAmount > balance - if (showNotification) { + val isExceedsBalance = sendingAmount + feeAmount > balance + if (isExceedsBalance) { onNotEnoughFeeNotificationShow() val notification = if (actionType is StakingActionCommonType.Enter) { NotificationUM.Error.TotalExceedsBalance @@ -315,7 +315,7 @@ internal class AddStakingNotificationsTransformer( currencyName = name, feeName = name, feeSymbol = symbol, - mergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), + mergeFeeNetworkName = BlockchainUtils.isArbitrum(network.rawId), onClick = { onClick(this) }, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt index 8628070ef4..02a41fe4a3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -150,10 +150,10 @@ internal class StakingInfoNotificationsFactory( val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val isTron = isTron(cryptoCurrencyStatus.currency.network.rawId) val hasStakedBalance = (cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit)?.balance - ?.items?.any { - it.type == BalanceType.PREPARING || - it.type == BalanceType.STAKED || - it.type == BalanceType.LOCKED + ?.items?.any { item -> + item.type == BalanceType.PREPARING || + item.type == BalanceType.STAKED || + item.type == BalanceType.LOCKED } == true if (isTron && hasStakedBalance) { add( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index eb514ea0ba..66cb40cb89 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -87,7 +87,7 @@ internal fun StakingInitialInfoContent( .background(TangemTheme.colors.background.secondary) .padding(horizontal = TangemTheme.dimens.spacing16), ) { - if (state.showBanner) { + if (state.isBannerVisible) { item(key = BANNER_BLOCK_KEY) { Column( modifier = Modifier.animateItem(), @@ -175,7 +175,7 @@ private fun LazyListScope.activeStakingBlock( ActiveStakingBlock( balance = balance, isBalanceHidden = isBalanceHidden, - onClick = clickIntents::onActiveStake, + onClick = { clickIntents.onActiveStake(balance) }, onAnalytic = clickIntents::onActiveStakeAnalytic, modifier = Modifier .animateItem() @@ -288,7 +288,7 @@ private fun StakingRewardBlock( private fun ActiveStakingBlock( balance: BalanceState, isBalanceHidden: Boolean, - onClick: (BalanceState) -> Unit, + onClick: () -> Unit, onAnalytic: () -> Unit, modifier: Modifier = Modifier, ) { @@ -304,7 +304,7 @@ private fun ActiveStakingBlock( enabled = balance.isClickable, onClick = { onAnalytic() - onClick(balance) + onClick() }, ) .padding(TangemTheme.dimens.spacing12), @@ -351,20 +351,18 @@ private fun ActiveStakingBlock( style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, ) - if (balance.formattedCryptoAmount != null) { - Text( - text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), - ) - } + Text( + text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) } } } @Composable -private fun RowScope.StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { +private fun StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { if (balance.hasImage() || icon != null) { StakingTargetIcon( image = if (balance.hasImage()) balance.target?.image.toImageReference() else null, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt index 60ba58a048..8ad250c6d1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt @@ -109,8 +109,8 @@ private fun BoxScope.FeeLoading(feeState: FeeState) { targetState = feeState, label = "Fee Loading State Change", modifier = Modifier.align(Alignment.CenterEnd), - ) { - if (it == FeeState.Loading) { + ) { state -> + if (state == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( @@ -128,8 +128,8 @@ private fun BoxScope.FeeError(feeState: FeeState) { targetState = feeState, label = "Fee Error State Change", modifier = Modifier.align(Alignment.CenterEnd), - ) { - if (it == FeeState.Error) { + ) { state -> + if (state == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, @@ -151,13 +151,6 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va private class FeeBlockPreviewProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - contentState, - FeeState.Loading, - FeeState.Error, - ) - private val fee = Fee.Common( amount = Amount( currencySymbol = "MATIC", @@ -174,6 +167,13 @@ private class FeeBlockPreviewProvider : PreviewParameterProvider { isFeeApproximate = false, isFeeConvertibleToFiat = true, ) + + override val values: Sequence + get() = sequenceOf( + contentState, + FeeState.Loading, + FeeState.Error, + ) } // endregion \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelAmountTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelAmountTest.kt new file mode 100644 index 0000000000..a5abf73429 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelAmountTest.kt @@ -0,0 +1,170 @@ +package com.tangem.features.staking.impl.presentation.model + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.transformers.amount.* +import com.tangem.features.staking.impl.presentation.state.transformers.notifications.DismissStakingNotificationsStateTransformer +import com.tangem.utils.transformer.Transformer +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelAmountTest : StakingModelTestBase() { + + @Test + fun `WHEN onAmountPasteTriggerDismiss THEN AmountPasteDismissStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountPasteTriggerDismiss() + + verify { + stateController.update( + transformer = match> { it is AmountPasteDismissStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onMaxValueClick THEN analytics sent and AmountMaxValueStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onMaxValueClick() + + verify { + analyticsEventHandler.send( + match { + it is StakingAnalyticsEvent.ButtonMax + } + ) + } + verify { + stateController.update( + match> { it is AmountMaxValueStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onCurrencyChangeClick THEN analytics sent and AmountCurrencyChangeStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onCurrencyChangeClick(isFiat = true) + + verify { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.AmountSelectCurrency }) + } + verify { + stateController.update( + transformer = match> { it is AmountCurrencyChangeStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceByClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceByClick( + reduceAmountBy = BigDecimal.ONE, + reduceAmountByDiff = BigDecimal.TEN, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceByStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { it is DismissStakingNotificationsStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceToClick THEN AmountReduceToStateTransformer and DismissNotification applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceToClick( + reduceAmountTo = BigDecimal.ONE, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceToStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is DismissStakingNotificationsStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceByFeeClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = + runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceByFeeClick( + reduceAmount = BigDecimal.ONE, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceByStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is DismissStakingNotificationsStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onNotificationCancel THEN DismissStakingNotificationsStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNotificationCancel(NotificationUM::class.java) + + verify { + stateController.update( + transformer = match> { it is DismissStakingNotificationsStateTransformer } + ) + } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelInitTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelInitTest.kt new file mode 100644 index 0000000000..d888bc82b8 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelInitTest.kt @@ -0,0 +1,327 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.balancehiding.BalanceHidingSettings +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.staking.YieldBalanceItem +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.stakekit.action.StakingAction +import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingStep +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.transformers.HideBalanceStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateLoadingTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetInitialDataStateTransformer +import com.tangem.utils.logging.TangemLogger +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelInitTest : StakingModelTestBase() { + + @Test + fun `GIVEN currency status emitted WHEN model created THEN analytics sent and fee status fetched`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.value } returns mockk { + every { stakingBalance } returns mockk { + every { balance } returns YieldBalanceItem( + items = listOf( + mockk { every { validatorAddress } returns "address1" }, + mockk { every { validatorAddress } returns "address2" }, + ), + integrationId = "test" + ) + } + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { + paramsInterceptorHolder.addParamsInterceptor( + match { it.id() == "StakingParamsInterceptorId" } + ) + } + verify { + analyticsEventHandler.send( + StakingAnalyticsEvent.StakingInfoScreenOpened( + validatorsCount = 2 + ), + ) + } + verify { + stateController.initializeWithUserWallet(testUserWallet) + } + + model.onDestroy() + } + + @Test + fun `GIVEN currency status emitted twice WHEN model created THEN analytics sent only once`() = runTest { + val statusFlow = MutableSharedFlow() + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns statusFlow + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + statusFlow.emit(testAccountCurrencyStatus) + advanceUntilIdle() + statusFlow.emit(testAccountCurrencyStatus) + advanceUntilIdle() + + verify(exactly = 1) { + analyticsEventHandler.send( + event = StakingAnalyticsEvent.StakingInfoScreenOpened(validatorsCount = 0) + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN account initialized WHEN checkForTonHeatupCase THEN no error logged`() = runTest { + mockkObject(TangemLogger) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { checkAccountInitializedUseCase(testUserWalletId, any()) } + verify(exactly = 0) { TangemLogger.e(any(), any()) } + + model.onDestroy() + unmockkObject(TangemLogger) + } + + @Test + fun `GIVEN checkAccountInitialized fails WHEN checkForTonHeatupCase THEN error logged`() = runTest { + val testError = RuntimeException("network error") + coEvery { + checkAccountInitializedUseCase(testUserWalletId, any()) + } returns Either.Left(testError) + mockkObject(TangemLogger) + every { TangemLogger.e(any(), any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { TangemLogger.e("Error", testError) } + + model.onDestroy() + unmockkObject(TangemLogger) + } + + @Test + fun `GIVEN approval needed WHEN setupApprovalNeeded THEN getAllowanceUseCase called`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN approval needed AND getAllowance fails WHEN setupApprovalNeeded THEN no crash`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Left(RuntimeException("allowance error")) // error + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN any token staked WHEN setupIsAnyTokenStaked THEN use case called with correct wallet id`() = runTest { + coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(true) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { isAnyTokenStakedUseCase(testUserWalletId) } + + model.onDestroy() + } + + @Test + fun `GIVEN subtract available WHEN checkIfSubtractAvailable THEN use case called with correct params`() = runTest { + coEvery { + isAmountSubtractAvailableUseCase(testUserWalletId, any()) + } returns Either.Right(true) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { isAmountSubtractAvailableUseCase(testUserWalletId, any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN actions emitted WHEN subscribeOnActionsUpdates AND isInitState THEN updateInitialData`() = runTest { + val testActions = listOf(mockk(relaxed = true)) + every { + getActionsUseCase(testUserWalletId, any()) + } returns flowOf(Either.Right(testActions)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify(atLeast = 1) { + stateController.updateAll( + match { it is SetInitialDataStateTransformer }, + any(), + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN init step WHEN subscribeOnStepChanges THEN updateInitialData and partialUpdate called`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + verify(atLeast = 1) { + stateController.updateAll( + match { it is SetInitialDataStateTransformer }, + any(), + ) + } + coVerify { mockBalanceUpdater.partialUpdate() } + + model.onDestroy() + } + + @Test + fun `GIVEN assent step AND isWarning WHEN subscribeOnStepChanges THEN getFee AND amount rounded to integer`() = + runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk { + every { innerState } returns InnerConfirmationStakingState.ASSENT + } + every { amountState } returns mockk { + every { amountTextField } returns mockk { + every { isWarning } returns true + } + } + } + uiStateFlow.value = assentUiState + advanceUntilIdle() + + verify { + stateController.update( + match> { it is SetConfirmationStateLoadingTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN balance hidden WHEN subscribeOnBalanceHiding THEN HideBalanceStateTransformer applied`() = runTest { + val balanceHidingSettings = BalanceHidingSettings( + isHidingEnabledInSettings = true, + isBalanceHidden = true, + isBalanceHidingNotificationEnabled = false, + ) + every { getBalanceHidingSettingsUseCase() } returns flowOf(balanceHidingSettings) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { + stateController.update( + match> { it is HideBalanceStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onDestroy THEN params interceptor removed`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onDestroy() + + verify { + paramsInterceptorHolder.removeParamsInterceptor("StakingParamsInterceptorId") + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt new file mode 100644 index 0000000000..00d51a6c28 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt @@ -0,0 +1,501 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.analytics.models.Basic +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType +import com.tangem.features.staking.impl.presentation.state.transformers.* +import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountMaxValueStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelNavigationTest : StakingModelTestBase() { + + @Test + fun `WHEN onBackClick THEN router pop and stateController clear called`() = runTest { + every { stateController.value } returns initialUiState + every { appRouter.pop(any()) } just Runs + every { stateController.clear() } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onBackClick() + + verify { appRouter.pop(any()) } + verify { stateController.clear() } + + model.onDestroy() + } + + @Test + fun `GIVEN targets AND no yield balance WHEN onNextClick with balance THEN validators unavailable alert sent`() = + runTest { + every { messageSender.send(any()) } just Runs + every { testYield.allValidatorsFull } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + messageSender.send( + match { it is DialogMessage } // dialog from StakingModel.stakingEventFactory + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN partial amount disabled WHEN onNextClick with null balance THEN updateAll called with transformers`() = + runTest { + every { stateController.value } returns initialUiState + every { testYield.args.enter.isPartialAmountDisabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + match { it is ValidatorSelectChangeTransformer }, + match { it is SetAmountDataTransformer }, + match { it is AmountMaxValueStateTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN non-initial step WHEN onNextClick THEN only stakingStateRouter onNextClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { stateController.value } returns initialUiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + val amountUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Amount + } + uiStateFlow.value = amountUiState + every { stateController.value } returns amountUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + stateController.update(match<(StakingUiState) -> StakingUiState> { true }) + } + verify(exactly = 0) { + stateController.updateAll(*anyVararg()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN assent state AND no approval in progress WHEN onPrevClick THEN prev navigated and assent reset`() = + runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { notifications } returns persistentListOf() + } + } + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + every { stateController.update(any>()) } just Runs + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { stateController.value } returns assentUiState + + model.onPrevClick() + + verify { + stateController.update( + match> { + it is SetConfirmationStateResetAssentTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN in progress state WHEN onPrevClick THEN nothing happens`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val inProgressUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.IN_PROGRESS + } + } + uiStateFlow.value = inProgressUiState + every { stateController.value } returns inProgressUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + + model.onPrevClick() + + verify(exactly = 0) { stateController.update(any>()) } + verify(exactly = 0) { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + verify(exactly = 0) { stateController.updateAll(*anyVararg()) } + + model.onDestroy() + } + + @Test + fun `GIVEN completed state WHEN onPrevClick THEN onNextClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val completedUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.COMPLETED + } + } + uiStateFlow.value = completedUiState + every { stateController.value } returns completedUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + every { stateController.value } returns completedUiState + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { stateController.clear() } just Runs + + model.onPrevClick() + advanceUntilIdle() + + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN non-confirmation step WHEN onPrevClick THEN stakingStateRouter onPrevClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { stateController.value } returns initialUiState + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { appRouter.pop(any()) } just Runs + every { stateController.clear() } just Runs + + val amountUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Amount + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + uiStateFlow.value = amountUiState + every { stateController.value } returns amountUiState + every { stateController.uiState } returns MutableStateFlow(amountUiState) + + model.onPrevClick() + + verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + + model.onDestroy() + } + + @Test + fun `WHEN onRefreshSwipe true THEN loading set and balanceUpdater partialUpdate called`() = runTest { + val testAppScope = object : AppCoroutineScope, + CoroutineScope by this {} + + val model = createModel( + testScope = this, + coroutineScope = testAppScope, + ) + advanceUntilIdle() + + model.onRefreshSwipe(isRefreshing = true) + advanceUntilIdle() + + verify { + stateController.update( + match> { + it is SetInitialLoadingStateTransformer + } + ) + } + coVerify { mockBalanceUpdater.partialUpdate() } + + model.onDestroy() + } + + @Test + fun `WHEN onInitialInfoBannerClick THEN analytics sent and url opened`() = runTest { + val expectedUrl = "https://tangem.com/blog/post/how-to-stake-cryptocurrency/?utm_source=tangem-app" + mockkObject(TangemBlogUrlBuilder) + coEvery { TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowToStake) } returns expectedUrl + every { innerRouter.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onInitialInfoBannerClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.WhatIsStaking }) + } + verify { innerRouter.openUrl(expectedUrl) } + + model.onDestroy() + unmockkObject(TangemBlogUrlBuilder) + } + + @Test + fun `WHEN onInfoClick THEN ShowInfoBottomSheetStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) + + verify { + stateController.update( + match> { + it is ShowInfoBottomSheetStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN empty preferredTargets WHEN onAmountEnterClick THEN noAvailableValidators alert sent`() = runTest { + every { testYield.preferredValidators } returns emptyList() + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountEnterClick() + + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN non-empty preferredTargets WHEN onAmountEnterClick THEN validator reset and onNextClick called`() = + runTest { + every { stateController.value } returns initialUiState + every { testYield.preferredValidators } returns listOf(mockk(relaxed = true)) + every { initialUiState.actionType } returns StakingActionCommonType.Enter(skipEnterAmount = false) + every { testYield.args.enter.isPartialAmountDisabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountEnterClick() + advanceUntilIdle() + + verify { + stateController.updateAll( + match { it is ValidatorSelectChangeTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN txUrl not null WHEN onExploreClick THEN analytics sent and url opened`() = runTest { + val txUrl = "https://explorer.solana.com/tx/abc123" + val transactionDoneState = TransactionDoneState.Content( + timestamp = 1000L, + txUrl = txUrl, + ) + val confirmationState = mockk(relaxed = true) { + every { this@mockk.transactionDoneState } returns transactionDoneState + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + } + every { stateController.uiState } returns MutableStateFlow(uiState) + every { innerRouter.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onExploreClick() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonExplore }) } + verify { innerRouter.openUrl(txUrl) } + + model.onDestroy() + } + + @Test + fun `GIVEN txUrl not null WHEN onShareClick THEN analytics sent and shareManager called`() = runTest { + val txUrl = "https://explorer.solana.com/tx/abc123" + val transactionDoneState = TransactionDoneState.Content( + timestamp = 1000L, + txUrl = txUrl, + ) + val confirmationState = mockk(relaxed = true) { + every { this@mockk.transactionDoneState } returns transactionDoneState + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + } + every { stateController.uiState } returns MutableStateFlow(uiState) + every { vibratorHapticManager.performOneTime(any()) } just Runs + every { shareManager.shareText(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onShareClick() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonShare }) } + verify { vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) } + verify { shareManager.shareText(txUrl) } + + model.onDestroy() + } + + @Test + fun `WHEN onFailedTxEmailClick THEN analytics sent and sendFeedbackEmail called`() = runTest { + coEvery { getWalletMetaInfoUseCase(userWalletId = any()) } returns Either.Right(mockk(relaxed = true)) + every { saveBlockchainErrorUseCase(error = any()) } just Runs + coEvery { sendFeedbackEmailUseCase(type = any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onFailedTxEmailClick("test error") + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { it is Basic.ButtonSupport }) } + coVerify { sendFeedbackEmailUseCase(match { it is FeedbackEmailType.StakingProblem }) } + + model.onDestroy() + } + + @Test + fun `WHEN openTokenDetails THEN innerRouter openTokenDetails called`() = runTest { + every { innerRouter.openTokenDetails(any(), any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + val currency: CryptoCurrency = mockk(relaxed = true) + model.openTokenDetails(currency) + + verify { innerRouter.openTokenDetails(testUserWalletId, currency) } + + model.onDestroy() + } + + @Test + fun `WHEN showPrimaryClickAlert THEN messageSender sends alert`() = runTest { + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showPrimaryClickAlert() + + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `WHEN onOpenLearnMoreAboutApproveClick THEN urlOpener opens approve url`() = runTest { + val expectedUrl = "https://tangem.com/blog/post/give-revoke-permission/?utm_source=tangem-app" + mockkObject(TangemBlogUrlBuilder) + coEvery { TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission) } returns expectedUrl + every { urlOpener.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onOpenLearnMoreAboutApproveClick() + advanceUntilIdle() + + verify { urlOpener.openUrl(expectedUrl) } + + unmockkObject(TangemBlogUrlBuilder) + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt new file mode 100644 index 0000000000..c0d02afd92 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt @@ -0,0 +1,228 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import arrow.core.right +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.ParamsInterceptorHolder +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.haptic.VibratorHapticManager +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.* +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.tokens.* +import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.approval.api.GiveApprovalFeatureToggles +import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.staking.impl.navigation.InnerStakingRouter +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStep +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater +import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.BeforeEach + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class StakingModelTestBase { + + protected val testUserWalletId = UserWalletId("1234567890ABCDEF") + protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) + private val testIntegrationId = StakingIntegrationID.StakeKit.Coin.Solana + private val testParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = testIntegrationId, + ) + protected val testYield: Yield = mockk(relaxed = true) + protected val testUserWallet: UserWallet = mockk(relaxed = true) + protected val initialUiState: StakingUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.InitialInfo + } + + private lateinit var testCryptoCurrencyStatus: CryptoCurrencyStatus + private lateinit var testAccountCurrencyStatus: AccountCryptoCurrencyStatus + protected lateinit var mockBalanceUpdater: StakingBalanceUpdater + + protected val stateController: StakingStateController = mockk() + private val getYieldUseCase: GetYieldUseCase = mockk() + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + protected val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk() + protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + protected val appRouter: AppRouter = mockk() + + protected val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk() + protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk() + protected val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk() + protected val sendTransactionUseCase: SendTransactionUseCase = mockk() + protected val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk() + protected val getAllowanceUseCase: GetAllowanceUseCase = mockk() + protected val vibratorHapticManager: VibratorHapticManager = mockk() + protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk() + protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk() + protected val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk() + protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() + protected val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() + protected val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase = mockk() + private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase = mockk() + protected val stakingOperationsFactory: StakingOperationsFactory = mockk() + protected val stakingBalanceUpdater: StakingBalanceUpdater.Factory = mockk() + protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk() + protected val getActionsUseCase: GetActionsUseCase = mockk() + protected val p2pEthPoolRepository: P2PEthPoolRepository = mockk() + protected val checkAccountInitializedUseCase: CheckAccountInitializedUseCase = mockk() + protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk() + protected val getFeeUseCase: GetFeeUseCase = mockk() + protected val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk() + protected val getActionRequirementAmountUseCase: GetActionRequirementAmountUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + protected val paramsInterceptorHolder: ParamsInterceptorHolder = mockk(relaxed = true) + protected val shareManager: ShareManager = mockk() + protected val urlOpener: UrlOpener = mockk() + private val coroutineScope: AppCoroutineScope = mockk() + protected val innerRouter: InnerStakingRouter = mockk() + protected val messageSender: UiMessageSender = mockk() + protected val giveApprovalFeatureToggles: GiveApprovalFeatureToggles = mockk() + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + + val (status, accountStatus) = createMockedAccountCurrencyStatus() + testCryptoCurrencyStatus = status + testAccountCurrencyStatus = accountStatus + + coEvery { getYieldUseCase(testIntegrationId.value) } returns Either.Right(testYield) + every { getSelectedAppCurrencyUseCase() } returns flowOf(Either.Right(AppCurrency.Default)) + every { stateController.uiState } returns MutableStateFlow(initialUiState) + every { stateController.initializeWithUserWallet(any()) } just Runs + every { stateController.updateAll(*anyVararg()) } just Runs + every { stateController.update(any>()) } just Runs + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { getUserWalletUseCase(testUserWalletId) } returns Either.Right(testUserWallet) + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { checkAccountInitializedUseCase(testUserWalletId, any()) } returns true.right() + coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(false) + coEvery { + isAmountSubtractAvailableUseCase(testUserWalletId, any()) + } returns Either.Right(false) + every { getActionsUseCase(testUserWalletId, any()) } returns emptyFlow() + every { getBalanceHidingSettingsUseCase() } returns emptyFlow() + mockBalanceUpdater = mockk { + coEvery { partialUpdate() } just Runs + } + every { + stakingBalanceUpdater.create(any(), any(), any()) + } returns mockBalanceUpdater + } + + @Suppress("LongParameterList") + protected fun createModel( + testScope: TestScope, + paramsContainer: ParamsContainer = MutableParamsContainer(testParams), + coroutineScope: AppCoroutineScope = this.coroutineScope, + ): StakingModel { + return StakingModel( + paramsContainer = paramsContainer, + stateController = stateController, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getUserWalletUseCase = getUserWalletUseCase, + sendTransactionUseCase = sendTransactionUseCase, + createApprovalTransactionUseCase = createApprovalTransactionUseCase, + getAllowanceUseCase = getAllowanceUseCase, + vibratorHapticManager = vibratorHapticManager, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, + getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, + getCurrencyCheckUseCase = getCurrencyCheckUseCase, + isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, + isAnyTokenStakedUseCase = isAnyTokenStakedUseCase, + invalidatePendingTransactionsUseCase = invalidatePendingTransactionsUseCase, + stakingOperationsFactory = stakingOperationsFactory, + stakingBalanceUpdater = stakingBalanceUpdater, + analyticsEventHandler = analyticsEventHandler, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + getActionsUseCase = getActionsUseCase, + getYieldUseCase = getYieldUseCase, + p2pEthPoolRepository = p2pEthPoolRepository, + checkAccountInitializedUseCase = checkAccountInitializedUseCase, + createTransferTransactionUseCase = createTransferTransactionUseCase, + getFeeUseCase = getFeeUseCase, + getNetworkAddressesUseCase = getNetworkAddressesUseCase, + getActionRequirementAmountUseCase = getActionRequirementAmountUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase, + paramsInterceptorHolder = paramsInterceptorHolder, + shareManager = shareManager, + urlOpener = urlOpener, + coroutineScope = coroutineScope, + innerRouter = innerRouter, + messageSender = messageSender, + giveApprovalFeatureToggles = giveApprovalFeatureToggles, + appRouter = appRouter, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + protected fun createMockedAccountCurrencyStatus(): Pair { + val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) + val testAccountCurrencyStatus = mockk { + every { component1() } returns mockk(relaxed = true) + every { component2() } returns testCryptoCurrencyStatus + } + return testCryptoCurrencyStatus to testAccountCurrencyStatus + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt new file mode 100644 index 0000000000..2a40d90554 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt @@ -0,0 +1,792 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateLoadingTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateResetAssentTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetTypeChangeTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.CompleteInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeErrorToTonInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeToTonInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.ShowTonInitializeBottomSheetTransformer +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelTransactionTest : StakingModelTestBase() { + + @Test + fun `WHEN getFee THEN loading state set and feeLoader called`() = runTest { + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.getFee() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetConfirmationStateLoadingTransformer + } + ) + } + coVerify { + mockFeeLoader.getFee(any(), any(), any(), any()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN StakeKit integration AND assent state WHEN onActionClick THEN sendTransaction called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any() + ) + } returns mockTransactionSender + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk { + every { innerState } returns InnerConfirmationStakingState.ASSENT + } + } + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetConfirmationStateInProgressTransformer + } + ) + } + coVerify { mockTransactionSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN P2PEthPool AND fee not increased WHEN onActionClick THEN sendTransaction called directly`() = runTest { + val p2pParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = StakingIntegrationID.P2PEthPool, + ) + coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + coEvery { + getBalanceNotEnoughForFeeWarningUseCase( + fee = any(), + userWalletId = any(), + tokenStatus = any(), + feeStatus = any() + ) + } returns Either.Right(null) + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any() + ) + } returns mockk(relaxed = true) + val newFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.ONE + } + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } coAnswers { + firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) + } + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any() + ) + } returns mockTransactionSender + val currentFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.TEN + } + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { feeState } returns mockk(relaxed = true) { + every { fee } returns currentFee + } + } + } + + val model = createModel( + paramsContainer = MutableParamsContainer(p2pParams), + testScope = this, + ) + advanceUntilIdle() + + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + coVerify { mockTransactionSender.send(any()) } + verify(exactly = 0) { messageSender.send(match { it is DialogMessage }) } + + model.onDestroy() + } + + @Test + fun `GIVEN P2PEthPool AND fee increased WHEN onActionClick THEN fee updated alert shown`() = runTest { + val p2pParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = StakingIntegrationID.P2PEthPool, + ) + coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + coEvery { + getBalanceNotEnoughForFeeWarningUseCase( + fee = any(), + userWalletId = any(), + tokenStatus = any(), + feeStatus = any() + ) + } returns Either.Right(null) + coEvery { + getCurrencyCheckUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns mockk(relaxed = true) + every { messageSender.send(any()) } just Runs + val newFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.TEN + } + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } coAnswers { + firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) + } + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any(), + ) + } returns mockTransactionSender + val currentFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.ONE + } + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { feeState } returns mockk(relaxed = true) { + every { fee } returns currentFee + } + } + } + + val model = createModel( + paramsContainer = MutableParamsContainer(p2pParams), + testScope = this, + ) + advanceUntilIdle() + + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + verify { + stateController.update( + match> { + it is SetConfirmationStateResetAssentTransformer + }, + ) + } + verify { messageSender.send(any()) } + coVerify(exactly = 0) { mockTransactionSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN gasless approval enabled WHEN showApprovalBottomSheet THEN approvalSlotNavigation activated`() = + runTest { + every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showApprovalBottomSheet() + + verify(exactly = 0) { + stateController.update( + transformer = match> { it is ShowApprovalBottomSheetTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN gasless disabled WHEN showApprovalBottomSheet THEN ShowApprovalBottomSheetTransformer applied`() = + runTest { + every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns false + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showApprovalBottomSheet() + + verify { + stateController.update( + transformer = match> { it is ShowApprovalBottomSheetTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onApproveTypeChange THEN SetApprovalBottomSheetTypeChangeTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onApproveTypeChange(ApproveType.LIMITED) + + verify { + stateController.update( + transformer = match> { it is SetApprovalBottomSheetTypeChangeTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN approval needed WHEN onApprovalClick THEN in progress set and createApprovalTransaction called`() = + runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + val expectedNetwork = mockk { + every { name } returns "KEK" + } + val testToken: CryptoCurrency.Token = mockk(relaxed = true) { + every { network } returns expectedNetwork + } + val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns testToken + } + val testAccountCurrencyStatus = mockk { + every { component1() } returns mockk(relaxed = true) + every { component2() } returns testCryptoCurrencyStatus + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + // Setup stakingApproval = Needed + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee( + onStakingFee = any(), + onStakingFeeError = any(), + onApprovalFee = any(), + onFeeError = any() + ) + } just Runs + } + val expectedApprovalTx = Either.Right(mockk(relaxed = true)) + coEvery { + createApprovalTransactionUseCase.invoke( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = any(), + fee = any(), + contractAddress = any(), + spenderAddress = any(), + ) + } returns expectedApprovalTx + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Right("txHash") + every { vibratorHapticManager.performOneTime(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + // Now override stateController.value with confirmation state after cryptoCurrencyStatus is initialized + val testFee: Fee.Common = mockk(relaxed = true) + val confirmationState = mockk(relaxed = true) { + every { feeState } returns mockk(relaxed = true) { + every { fee } returns testFee + } + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + every { bottomSheetConfig } returns null + } + every { stateController.value } returns uiState + + model.onApprovalClick() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetApprovalBottomSheetInProgressTransformer + }, + ) + } + coVerify { + sendTransactionUseCase( + txData = expectedApprovalTx.value, + userWallet = testUserWallet, + network = expectedNetwork, + ) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN approval needed AND amountState data WHEN getApprovalParams THEN returns non-null params`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + + mockkObject(StakingIntegrationID.Companion) + try { + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + val amountState = mockk(relaxed = true) { + every { amountTextField.value } returns "100" + } + val uiState = mockk(relaxed = true) { + every { this@mockk.amountState } returns amountState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + val result = model.getApprovalParams() + + assert(result != null) { "Expected non-null GiveApprovalComponent.Params" } + assert(result!!.spenderAddress == spenderAddress) { + "Expected spenderAddress=$spenderAddress, got=${result.spenderAddress}" + } + + model.onDestroy() + } finally { + unmockkObject(StakingIntegrationID.Companion) + } + } + + @Test + fun `GIVEN getFee returns Left WHEN onActivateTonAccountNotificationClick THEN fee error transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency } returns mockk { + every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") + every { symbol } returns "KEK" + every { network } returns mockk() + every { decimals } returns 2 + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Left(mockk(relaxed = true)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("KEK")) + } + verify { + stateController.update( + transformer = match> { it is ShowTonInitializeBottomSheetTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is SetFeeErrorToTonInitializeBottomSheetTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN getFee returns Right WHEN onActivateTonAccountNotificationClick THEN fee transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency } returns mockk { + every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") + every { symbol } returns "SHMEK" + every { network } returns mockk() + every { decimals } returns 2 + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("SHMEK")) + } + verify { + stateController.update( + transformer = match> { it is ShowTonInitializeBottomSheetTransformer } + ) + } + verify { + stateController.update( + match> { + it is SetFeeToTonInitializeBottomSheetTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onActivateTonAccountNotificationShow THEN UninitializedAddress analytics sent with token`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationShow() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddress(token = "TON")) + } + + model.onDestroy() + } + + @Test + fun `WHEN onNotEnoughFeeNotificationShow THEN NotEnoughFee analytics sent with token`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "SOL" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNotEnoughFeeNotificationShow() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.NotEnoughFee(token = "SOL")) + } + + model.onDestroy() + } + + @Test + fun `GIVEN sendTransaction returns Left WHEN onActivateTonAccountClick THEN error dialog sent`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Left(mockk(relaxed = true)) + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + // First populate tonAccountInitializeTransaction + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + model.onActivateTonAccountClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) + } + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN sendTransaction returns Right WHEN onActivateTonAccountClick THEN complete transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + val mockBalanceUpdater: StakingBalanceUpdater = mockk { + coEvery { partialUpdate() } just Runs + coEvery { partialUpdateWithDelay() } just Runs + } + every { + stakingBalanceUpdater.create(any(), any(), any()) + } returns mockBalanceUpdater + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Right("txHash") + + val model = createModel(testScope = this) + advanceUntilIdle() + + // First populate tonAccountInitializeTransaction + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + model.onActivateTonAccountClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) + } + verify { + stateController.update( + match> { it is CompleteInitializeBottomSheetTransformer }, + ) + } + coVerify { mockBalanceUpdater.partialUpdateWithDelay() } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt new file mode 100644 index 0000000000..8a891e3781 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt @@ -0,0 +1,351 @@ +package com.tangem.features.staking.impl.presentation.model + +import com.tangem.domain.models.staking.BalanceType +import com.tangem.domain.models.staking.PendingAction +import com.tangem.domain.models.staking.PendingActionConstraints +import com.tangem.domain.models.staking.RewardBlockType +import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.staking.analytics.StakeScreenSource +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingTarget +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.transformers.SetAmountDataTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateInitTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ShowActionSelectorBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelValidatorTest : StakingModelTestBase() { + + @Test + fun `WHEN openValidators THEN analytics sent and step changed to Validators`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openValidators() + + verify { + analyticsEventHandler.send( + match { it is StakingAnalyticsEvent.ButtonValidator }, + ) + } + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `WHEN onTargetSelect THEN analytics sent and ValidatorSelectChangeTransformer applied`() = runTest { + val target: StakingTarget = mockk(relaxed = true) { + every { name } returns "TestValidator" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onTargetSelect(target) + + verify { + analyticsEventHandler.send(event = StakingAnalyticsEvent.ValidatorChosen("TestValidator")) + } + verify { + stateController.update( + transformer = match> { it is ValidatorSelectChangeTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN no RewardsRequirementsError AND single reward WHEN openRewardsValidators THEN onActiveStake called`() = + runTest { + every { stateController.value } returns initialUiState + every { messageSender.send(any()) } just Runs + + val singleReward: BalanceState = mockk(relaxed = true) { + every { pendingActions } returns persistentListOf() + } + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.Rewards, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val rewardsValidatorsState = mockk(relaxed = true) { + every { rewards } returns persistentListOf(singleReward) + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + // onActiveStake path — ButtonValidator analytics should NOT be sent + verify(exactly = 0) { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonValidator }) + } + + model.onDestroy() + } + + @Test + fun `GIVEN no RewardsRequirementsError AND rewards WHEN openRewardsValidators THEN showRewardsValidators called`() = + runTest { + every { stateController.value } returns initialUiState + + val reward1: BalanceState = mockk(relaxed = true) + val reward2: BalanceState = mockk(relaxed = true) + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "2.0", + rewardsFiat = "$2.00", + rewardBlockType = RewardBlockType.Rewards, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val rewardsValidatorsState = mockk(relaxed = true) { + every { rewards } returns persistentListOf(reward1, reward2) + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { + analyticsEventHandler.send( + event = StakingAnalyticsEvent.ButtonValidator(source = StakeScreenSource.Info) + ) + } + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN RewardsRequirementsError AND minimumAmount WHEN openRewardsValidators THEN alert shown call`() = + runTest { + every { messageSender.send(any()) } just Runs + + val constraints = PendingActionConstraints( + type = StakingActionType.CLAIM_REWARDS, + amountArg = PendingAction.PendingActionArgs.Amount( + required = true, + minimum = BigDecimal.TEN, + maximum = null, + ), + ) + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.RewardsRequirementsError, + rewardConstraints = constraints, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { messageSender.send(any()) } + verify(exactly = 0) { getActionRequirementAmountUseCase.invoke(any(), any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN RewardsRequirementsError WHEN openRewardsValidators THEN getActionRequirementAmountUseCase called`() = + runTest { + every { messageSender.send(any()) } just Runs + every { + getActionRequirementAmountUseCase.invoke(any(), any()) + } returns BigDecimal.ONE + + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.RewardsRequirementsError, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { + getActionRequirementAmountUseCase.invoke( + integrationId = "test-integration", + actionType = StakingActionType.CLAIM_REWARDS + ) + } + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN single pending action WHEN onActiveStake THEN prepareForConfirmation and onNextClick called`() = + runTest { + every { stateController.value } returns initialUiState + + val singleAction = PendingAction( + type = StakingActionType.CLAIM_REWARDS, + passthrough = "test", + args = null, + ) + val activeStake: BalanceState = mockk(relaxed = true) { + every { type } returns BalanceType.STAKED + every { pendingActions } returns persistentListOf(singleAction) + every { target } returns null + every { cryptoValue } returns "100" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStake(activeStake) + advanceUntilIdle() + + // prepareForConfirmation calls updateAll with 4 transformers + verify { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + match { it is ValidatorSelectChangeTransformer }, + match { it is SetAmountDataTransformer }, + any(), + ) + } + // onNextClick updates step + verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + + model.onDestroy() + } + + @Test + fun `GIVEN multiple pending actions WHEN onActiveStake THEN ShowActionSelectorBottomSheetTransformer applied`() = + runTest { + every { stateController.value } returns initialUiState + + val action1 = PendingAction( + type = StakingActionType.CLAIM_REWARDS, + passthrough = "test1", + args = null, + ) + val action2 = PendingAction( + type = StakingActionType.WITHDRAW, + passthrough = "test2", + args = null, + ) + val activeStake: BalanceState = mockk(relaxed = true) { + every { type } returns BalanceType.STAKED + every { pendingActions } returns persistentListOf(action1, action2) + every { target } returns null + every { cryptoValue } returns "100" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStake(activeStake) + + verify { + stateController.update( + match> { it is ShowActionSelectorBottomSheetTransformer }, + ) + } + // prepareForConfirmation should NOT have been called + verify(exactly = 0) { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + any(), any(), any(), + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onActiveStakeAnalytic THEN ButtonValidator analytics sent`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStakeAnalytic() + + verify { + analyticsEventHandler.send( + StakingAnalyticsEvent.ButtonValidator( + source = StakeScreenSource.Info, + ) + ) + } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt index 1872af1c6d..0c7bf8ca39 100644 --- a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt @@ -18,24 +18,20 @@ internal object StoriesSlideConfigs { private fun swapSlides(): ImmutableList = persistentListOf( SlideConfig( - com.tangem.core.res.R.string.swap_story_first_title, - com.tangem.core.res.R.string.swap_story_first_subtitle, + com.tangem.core.res.R.string.swap_story_first_title_v2, + com.tangem.core.res.R.string.swap_story_first_subtitle_v2, ), SlideConfig( - com.tangem.core.res.R.string.swap_story_second_title, - com.tangem.core.res.R.string.swap_story_second_subtitle, + com.tangem.core.res.R.string.swap_story_second_title_v2, + com.tangem.core.res.R.string.swap_story_second_subtitle_v2, ), SlideConfig( - com.tangem.core.res.R.string.swap_story_third_title, - com.tangem.core.res.R.string.swap_story_third_subtitle, + com.tangem.core.res.R.string.swap_story_third_title_v2, + com.tangem.core.res.R.string.swap_story_third_subtitle_v2, ), SlideConfig( - com.tangem.core.res.R.string.swap_story_forth_title, - com.tangem.core.res.R.string.swap_story_forth_subtitle, - ), - SlideConfig( - com.tangem.core.res.R.string.swap_story_fifth_title, - com.tangem.core.res.R.string.swap_story_fifth_subtitle, + com.tangem.core.res.R.string.swap_story_forth_title_v2, + com.tangem.core.res.R.string.swap_story_forth_subtitle_v2, ), ) } \ No newline at end of file diff --git a/features/swap-v2/impl/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts index 76f57cbad9..0b55164642 100644 --- a/features/swap-v2/impl/build.gradle.kts +++ b/features/swap-v2/impl/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.features.swapV2.api) implementation(projects.features.manageTokens.api) implementation(projects.features.sendV2.api) + implementation(projects.features.commonFeatures.api) /** Core */ implementation(projects.core.decompose) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt index 4df2de3238..e4c1071209 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams.AmountBlockParams import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel @@ -82,7 +83,9 @@ internal class SwapAmountBlockComponent( context = childByContext(componentContext), params = SwapChooseProviderComponent.Params( providers = config.providers, - cryptoCurrency = config.cryptoCurrency, + fromCryptoCurrency = config.fromCryptoCurrency, + toCryptoCurrency = config.toCryptoCurrency, + amountType = config.amountType, selectedProvider = config.selectedProvider, userCountry = config.userCountry, callback = model, @@ -93,7 +96,9 @@ internal class SwapAmountBlockComponent( data class SwapChooseProviderConfig( val providers: ImmutableList, - val cryptoCurrency: CryptoCurrency, + val fromCryptoCurrency: CryptoCurrency, + val toCryptoCurrency: CryptoCurrency, + val amountType: SwapAmountType, val selectedProvider: ExpressProvider, val userCountry: UserCountry, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt index c0f9222dd3..ea00127113 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt @@ -86,6 +86,7 @@ sealed class SwapAmountFieldUM { val subtitleEllipsisLeft: TextEllipsis, val subtitleEllipsisRight: TextEllipsis, val isClickEnabled: Boolean, + val shouldShowApproximatePrefix: Boolean, ) : SwapAmountFieldUM() } @@ -95,7 +96,6 @@ data class PriceImpact( val amountSignificance: AmountSignificance, val type: Type, ) { - enum class Type { NONE, LOW, MEDIUM, HIGH } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 77a4272f1a..2bcecfac64 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -125,6 +125,7 @@ internal class SwapAmountModel @Inject constructor( private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler) private var autoUpdateSubscriberJob: Job? = null + private var navigationJob: Job? = null val uiState: StateFlow field = MutableStateFlow(params.amountUM) @@ -139,7 +140,6 @@ internal class SwapAmountModel @Inject constructor( ?: UserCountry.Other(Locale.getDefault().country) isShowBestRateAnimation = swapBestRateAnimationStore.getSyncOrNull() } - configAmountNavigation() subscribeOnCryptoCurrencyStatusFlow() subscribeOnAmountUpdateTriggerUpdates() observeChooseSelectToken() @@ -156,6 +156,7 @@ internal class SwapAmountModel @Inject constructor( } else { QUOTES_UPDATE_DELAY } + configAmountNavigation() quoteTaskScheduler.scheduleTask( scope = modelScope, task = loadQuotesTask(initialDelay = initialDelay), @@ -166,6 +167,7 @@ internal class SwapAmountModel @Inject constructor( fun onStop() { quoteTaskScheduler.cancelTask() autoUpdateSubscriberJob?.cancel() + navigationJob?.cancel() } override fun onDestroy() { @@ -373,7 +375,12 @@ internal class SwapAmountModel @Inject constructor( override fun onProviderClick() { val amountUM = uiState.value as? SwapAmountUM.Content ?: return val selectedProvider = amountUM.selectedQuote.provider ?: return - val cryptoCurrency = params.secondaryCryptoCurrency ?: return + val secondaryStatus = amountUM.secondaryCryptoCurrencyStatus ?: return + + val (fromCryptoCurrency, toCryptoCurrency) = amountUM.swapDirection.withSwapDirection( + onDirect = { amountUM.primaryCryptoCurrencyStatus.currency to secondaryStatus.currency }, + onReverse = { secondaryStatus.currency to amountUM.primaryCryptoCurrencyStatus.currency }, + ) analyticsEventHandler.send( SwapAmountAnalyticEvents.ProviderSelectorClicked( @@ -384,7 +391,9 @@ internal class SwapAmountModel @Inject constructor( bottomSheetNavigation.activate( SwapChooseProviderConfig( providers = amountUM.swapQuotes, - cryptoCurrency = cryptoCurrency, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountUM.selectedAmountType, selectedProvider = selectedProvider, userCountry = userCountry, ), @@ -618,6 +627,7 @@ internal class SwapAmountModel @Inject constructor( | Primary -> $primaryStatus | Secondary -> $secondaryStatus """.trimIndent(), + shouldSanitize = false, ) showErrorAlert(errorMessage = null) } @@ -901,7 +911,8 @@ internal class SwapAmountModel @Inject constructor( private fun configAmountNavigation() { val params = params as? SwapAmountComponentParams.AmountParams ?: return - combine( + navigationJob?.cancel() + navigationJob = combine( flow = uiState, flow2 = params.currentRoute, transform = { state, route -> state to route }, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt index 0684369b44..9f427e5e85 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.models.AmountParameters -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.* import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account @@ -39,6 +39,7 @@ internal class SwapAmountFieldConverter( isSelected: Boolean, isAmountEmpty: Boolean = true, displayAmount: BigDecimal? = null, + showApproximatePrefix: Boolean = false, ): SwapAmountFieldUM { val walletTitle = if (isSingleWallet) { resourceReference(R.string.send_from_title) @@ -60,6 +61,7 @@ internal class SwapAmountFieldConverter( subtitleRight = subtitles.subtitleRight, subtitleEllipsisRight = subtitles.subtitleEllipsisRight, isClickEnabled = true, + shouldShowApproximatePrefix = showApproximatePrefix, amountField = AmountStateConverter( clickIntents = clickIntents, appCurrency = appCurrency, @@ -77,7 +79,7 @@ internal class SwapAmountFieldConverter( walletTitle = walletTitle, prefixText = when { swapAmountType.isEnteringField() -> resourceReference(R.string.common_from) - else -> TextReference.Companion.EMPTY + else -> TextReference.EMPTY }, ).convert(account) }, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt index c1fde5d967..aea60fd110 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt @@ -35,6 +35,8 @@ internal class SwapAmountChangeAmountTypeTransformer( field = field, cryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus, isAmountEmpty = true, + ).copy( + shouldShowApproximatePrefix = swapRateType == ExpressRateType.Float, ) } ?: prevState.secondaryAmount } else { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt index f9de0f0a59..e2788cc4f1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt @@ -62,6 +62,7 @@ internal class SwapAmountSecondaryReadyStateTransformer( swapAmountType = SwapAmountType.To, cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, isSelected = prevState.selectedAmountType == SwapAmountType.To, + showApproximatePrefix = selectedRateType == ExpressRateType.Float, ), secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, swapCurrencies = swapCurrencies, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt index 271054c6a7..9131977708 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt @@ -64,7 +64,9 @@ internal class SwapAmountSelectQuoteTransformer( val hasInsufficientFundsForFixed = hasInsufficientFundsForFixed(prevState, quoteContent) return prevState.copy( - isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content && !hasInsufficientFundsForFixed, + isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content && + !hasInsufficientBalance(prevState, quoteContent, newPrimaryAmount) && + !hasInsufficientFundsForFixed, selectedQuote = quoteUM, isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true, primaryAmount = newPrimaryAmount, @@ -214,4 +216,22 @@ internal class SwapAmountSelectQuoteTransformer( prevState.secondaryAmount } } + + private fun hasInsufficientBalance( + prevState: SwapAmountUM.Content, + quoteContent: SwapQuoteUM.Content?, + newPrimaryAmount: SwapAmountFieldUM, + ): Boolean { + if (quoteContent == null) return false + + val primaryBalance = prevState.primaryCryptoCurrencyStatus.value.amount ?: return false + val fromAmount = if (prevState.selectedAmountType == SwapAmountType.To) { + quoteContent.fromAmount + } else { + val amountData = (newPrimaryAmount as? SwapAmountFieldUM.Content)?.amountField as? AmountState.Data + amountData?.amountTextField?.cryptoAmount?.value + } + + return fromAmount != null && fromAmount > primaryBalance + } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt index 6db639d48a..52c6ec8150 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt @@ -40,6 +40,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent @@ -120,11 +121,13 @@ private fun ConstraintLayoutScope.SwapAmountBlock( end.linkTo(parent.end) }, ) + val secondaryContent = amountUM.secondaryAmount as? SwapAmountFieldUM.Content AmountBlockV2( amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy( accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.send_with_swap_recipient_amount_title)), availableBalanceCrypto = TextReference.EMPTY, ) ?: amountUM.secondaryAmount.amountField, + shouldShowApproximatePrefix = secondaryContent?.shouldShowApproximatePrefix == true, isClickDisabled = true, isEditingDisabled = false, modifier = Modifier.constrainAs(toAmountRef) { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt index f15aca16d8..9be4de2099 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt @@ -33,7 +33,6 @@ internal data object SwapAmountContentPreview { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, @@ -109,6 +108,7 @@ internal data object SwapAmountContentPreview { isClickEnabled = false, subtitleEllipsisLeft = TextEllipsis.OffsetEnd(3), subtitleEllipsisRight = TextEllipsis.OffsetEnd(1), + shouldShowApproximatePrefix = false, ), secondaryAmount = SwapAmountFieldUM.Content( amountType = SwapAmountType.To, @@ -121,6 +121,7 @@ internal data object SwapAmountContentPreview { isClickEnabled = false, subtitleEllipsisLeft = TextEllipsis.End, subtitleEllipsisRight = TextEllipsis.End, + shouldShowApproximatePrefix = true, ), appCurrency = AppCurrency.Default, swapDirection = SwapDirection.Direct, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt index c04a88654c..351e5cbad0 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt @@ -11,6 +11,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.chooseprovider.model.SwapChooseProviderModel import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderBottomSheet import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent @@ -49,7 +50,9 @@ internal class SwapChooseProviderComponent( } data class Params( - val cryptoCurrency: CryptoCurrency, + val fromCryptoCurrency: CryptoCurrency, + val toCryptoCurrency: CryptoCurrency, + val amountType: SwapAmountType, val selectedProvider: ExpressProvider, val providers: ImmutableList, val userCountry: UserCountry, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt index dc784cc913..9d550636df 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt @@ -29,7 +29,9 @@ internal class SwapChooseProviderModel @Inject constructor( private val swapProviderListItemConverter by lazy(LazyThreadSafetyMode.NONE) { SwapProviderListItemConverter( - cryptoCurrency = params.cryptoCurrency, + fromCryptoCurrency = params.fromCryptoCurrency, + toCryptoCurrency = params.toCryptoCurrency, + amountType = params.amountType, selectedProvider = params.selectedProvider, isNeedApplyFCARestrictions = isNeedApplyFCARestrictions, needBestRateBadge = params.providers.filterIsInstance().isSingleItem().not(), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt index 7383f568e4..8efc25af94 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt @@ -12,21 +12,33 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderListItem import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.common.isRestrictedByFCA +import com.tangem.features.swap.v2.impl.common.resolveAmountErrorCurrency import com.tangem.utils.converter.Converter internal class SwapProviderListItemConverter( - private val cryptoCurrency: CryptoCurrency, + private val fromCryptoCurrency: CryptoCurrency, + private val toCryptoCurrency: CryptoCurrency, + private val amountType: SwapAmountType, private val selectedProvider: ExpressProvider, private val isNeedApplyFCARestrictions: Boolean, needBestRateBadge: Boolean, ) : Converter { + private val amountErrorCurrency: CryptoCurrency = resolveAmountErrorCurrency( + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountType, + ) + private val providerStateConverter = SwapProviderStateConverter( - cryptoCurrency = cryptoCurrency, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountType, selectedProvider = selectedProvider, isNeedApplyFCARestrictions = isNeedApplyFCARestrictions, isNeedBestRateBadge = needBestRateBadge, @@ -63,7 +75,7 @@ internal class SwapProviderListItemConverter( is ExpressError.AmountError.TooSmallError -> resourceReference( id = R.string.express_provider_min_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) is ExpressError.AmountError.NotEnoughAllowanceError, @@ -71,7 +83,7 @@ internal class SwapProviderListItemConverter( -> resourceReference( id = R.string.express_provider_max_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) else -> TextReference.EMPTY diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt index a591206770..76db9382b1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt @@ -8,21 +8,31 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderState import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderState.AdditionalBadge import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.common.isRestrictedByFCA +import com.tangem.features.swap.v2.impl.common.resolveAmountErrorCurrency import com.tangem.utils.converter.Converter @Deprecated("Remove with new design") internal class SwapProviderStateConverter( - private val cryptoCurrency: CryptoCurrency, + private val fromCryptoCurrency: CryptoCurrency, + private val toCryptoCurrency: CryptoCurrency, + private val amountType: SwapAmountType, private val selectedProvider: ExpressProvider, private val isNeedBestRateBadge: Boolean, private val isNeedApplyFCARestrictions: Boolean, ) : Converter { + private val amountErrorCurrency: CryptoCurrency = resolveAmountErrorCurrency( + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountType, + ) + override fun convert(value: SwapQuoteUM): SwapProviderState { return when (value) { is SwapQuoteUM.Content -> value.convertToContent() @@ -71,7 +81,7 @@ internal class SwapProviderStateConverter( is ExpressError.AmountError.TooSmallError -> resourceReference( id = R.string.express_provider_min_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) is ExpressError.AmountError.NotEnoughAllowanceError, @@ -79,7 +89,7 @@ internal class SwapProviderStateConverter( -> resourceReference( id = R.string.express_provider_max_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) else -> TextReference.EMPTY diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt index b40d57b863..1567523f5c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt @@ -23,12 +23,12 @@ import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transfor import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList") @@ -76,8 +76,8 @@ internal class SwapChooseTokenNetworkModel @Inject constructor( val cryptoCurrencyList = createCryptoCurrencyUseCase( token = params.token, userWalletId = params.userWalletId, - ).getOrElse { - TangemLogger.e("Failed to get crypto currency") + ).getOrElse { throwable -> + TangemLogger.e("Failed to get crypto currency", throwable) swapChooseTokenAlertFactory.getGenericErrorState(params.onDismiss) return@launch } @@ -88,7 +88,7 @@ internal class SwapChooseTokenNetworkModel @Inject constructor( filterProviderTypes = SEND_WITH_SWAP_PROVIDER_TYPES, swapTxType = SwapTxType.SendWithSwap, ).getOrElse { error -> - TangemLogger.e(error.toString()) + TangemLogger.e("Error", error) uiState.update( SwapChooseErrorStateTransformer( tokenName = params.token.name, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt index 3868504cd6..438a0673b8 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt @@ -1,6 +1,6 @@ package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency @@ -29,7 +29,7 @@ internal class SwapChooseContentStateTransformer( val isMain = cryptoCurrency is CryptoCurrency.Coin val subtitle = when { - BlockchainUtils.isL2Network(networkId = network.backendId) -> MAIN_NETWORK_L2_TYPE_NAME + BlockchainUtils.isL2Network(networkId = network.rawId) -> MAIN_NETWORK_L2_TYPE_NAME isMain -> MAIN_NETWORK_TYPE_NAME network.standardType !is Network.StandardType.Unspecified -> network.standardType.name else -> "" diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolver.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolver.kt new file mode 100644 index 0000000000..3420a9673e --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolver.kt @@ -0,0 +1,13 @@ +package com.tangem.features.swap.v2.impl.common + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType + +internal fun resolveAmountErrorCurrency( + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + amountType: SwapAmountType?, +): CryptoCurrency = when (amountType) { + SwapAmountType.To -> toCryptoCurrency + SwapAmountType.From, null -> fromCryptoCurrency +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt index 22ead70b9b..7e53fda3a2 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt @@ -75,8 +75,7 @@ internal class SwapAlertFactory @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency?.network?.rawId.orEmpty(), - derivationPath = cryptoCurrency?.network?.derivationPath?.value.orEmpty(), + networkId = cryptoCurrency?.network?.id, destinationAddress = confirmData?.enteredDestination.orEmpty(), tokenSymbol = confirmData?.toCryptoCurrencyStatus?.currency?.symbol.orEmpty(), amount = confirmData?.enteredFromAmount?.toString().orEmpty(), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt index c7e62ed4f3..9825830ed6 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt @@ -7,10 +7,10 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider -import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact import java.math.BigDecimal import com.tangem.features.swap.v2.impl.notifications.model.SwapNotificationsModel @@ -54,7 +54,7 @@ internal class SwapNotificationsComponent( val fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null, val priceImpact: PriceImpact? = null, val provider: ExpressProvider? = null, - val rateType: ExpressRateType? = null, + val amountType: SwapAmountType? = null, val shouldIncludeFeeInBalanceCheck: Boolean = false, val feeValue: BigDecimal? = null, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt index d82b8ace85..c8e796d663 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt @@ -8,10 +8,10 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError -import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact +import com.tangem.features.swap.v2.impl.common.resolveAmountErrorCurrency import com.tangem.features.swap.v2.impl.notifications.DefaultSwapNotificationsUpdateTrigger import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent.Params.SwapNotificationData @@ -152,11 +152,11 @@ internal class SwapNotificationsModel @Inject constructor( val fromCryptoCurrency = notificationData.fromCryptoCurrency ?: return val toCryptoCurrency = notificationData.toCryptoCurrencyStatus?.currency ?: return - val amountErrorCurrency = if (notificationData.rateType == ExpressRateType.Fixed) { - toCryptoCurrency - } else { - fromCryptoCurrency - } + val amountErrorCurrency = resolveAmountErrorCurrency( + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = notificationData.amountType, + ) val errorNotification = when (expressError) { is ExpressError.AmountError.TooSmallError -> SwapNotificationUM.Error.MinimalAmountError( 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 387432f1fd..332b0b63f3 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 @@ -141,7 +141,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( enteredFromAmount = model.confirmData.enteredFromAmount, fromCryptoCurrencyStatus = model.confirmData.fromCryptoCurrencyStatus, priceImpact = model.confirmData.priceImpact, - rateType = model.confirmData.rateType, + amountType = model.confirmData.amountType, ), ), ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 082d7d8ffa..7e59abe87e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -17,7 +17,6 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase @@ -100,7 +99,6 @@ internal class SendWithSwapConfirmModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val swapAlertFactory: SwapAlertFactory, private val analyticsEventHandler: AnalyticsEventHandler, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, swapTransactionSenderFactory: SwapTransactionSender.Factory, paramsContainer: ParamsContainer, ) : Model(), FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -462,7 +460,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( fromCryptoCurrencyStatus = confirmData.fromCryptoCurrencyStatus, priceImpact = confirmData.priceImpact, provider = confirmData.quote?.provider, - rateType = confirmData.rateType, + amountType = confirmData.amountType, shouldIncludeFeeInBalanceCheck = isFixedRate && isAmountSubtractAvailable, feeValue = confirmData.fee?.amount?.value, ), @@ -574,8 +572,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( val confirmUM = state.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isTransactionInProcess - val isHoldToConfirm = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - params.userWallet.isHotWallet && isContent + val isHoldToConfirm = params.userWallet.isHotWallet && isContent params.callback.onResult( route = SendWithSwapRoute.Confirm, sendWithSwapUM = state.copy( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index 9f9d5ff8da..bfe0696e43 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -26,12 +26,11 @@ import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.swap.v2.impl.common.ConfirmData import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal -import java.math.RoundingMode @Suppress("LongParameterList") internal class SwapTransactionSender @AssistedInject constructor( @@ -68,7 +67,10 @@ internal class SwapTransactionSender @AssistedInject constructor( ExpressProviderType.DEX_BRIDGE, ExpressProviderType.ONRAMP, -> { - TangemLogger.w("Provider $providerType is not supported in Send With Swap") + TangemLogger.i( + messageString = "Provider $providerType is not supported in Send With Swap", + shouldSanitize = false, + ) onExpressError(ExpressError.UnknownError) } } @@ -91,7 +93,7 @@ internal class SwapTransactionSender @AssistedInject constructor( val feeValue = confirmData.fee?.amount?.value ?: return val destination = confirmData.enteredDestination ?: return - val (swapDataRequestAmount, swapDataRequestCurrency) = when (confirmData.amountType) { + val swapDataRequestAmount = when (confirmData.amountType) { SwapAmountType.From -> { val amountValue = confirmData.enteredFromAmount ?: return val subtracted = FeeCalculationUtils.checkAndCalculateSubtractedAmount( @@ -101,11 +103,11 @@ internal class SwapTransactionSender @AssistedInject constructor( feeValue = feeValue, reduceAmountBy = confirmData.reduceAmountBy, ) - subtracted to fromStatus + subtracted } SwapAmountType.To -> { val amountValue = confirmData.enteredToAmount ?: return - amountValue to toStatus + amountValue } } @@ -117,7 +119,7 @@ internal class SwapTransactionSender @AssistedInject constructor( val swapData = getSwapDataUseCase( userWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, - amount = swapDataRequestAmount.toStringWithRightOffset(swapDataRequestCurrency.currency.decimals), + amount = swapDataRequestAmount, amountType = confirmData.amountType, toCryptoCurrency = toStatus.currency, toAddress = destination, @@ -208,7 +210,8 @@ internal class SwapTransactionSender @AssistedInject constructor( ifRight = { txHash -> val timestamp = System.currentTimeMillis() swapTransactionSentUseCase.invoke( - userWallet = userWallet, + fromUserWallet = userWallet, + toUserWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, toCryptoCurrencyStatus = toStatus, fromAccount = fromAccount, @@ -225,10 +228,6 @@ internal class SwapTransactionSender @AssistedInject constructor( ) } - private fun BigDecimal.toStringWithRightOffset(decimals: Int): String { - return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() - } - @AssistedFactory interface Factory { fun create(userWallet: UserWallet): SwapTransactionSender diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt index 0ec7b02ecd..e991accc0c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -17,7 +18,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.api.entity.FeeSelectorUM @@ -32,9 +32,9 @@ import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.confirm.SendWithSwapConfirmComponent import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject import kotlin.properties.Delegates @@ -159,7 +159,7 @@ internal class SendWithSwapModel @Inject constructor( getPrimaryCurrencyStatusUpdates(params.currency) }, ifLeft = { error -> - TangemLogger.w(error.toString()) + TangemLogger.e(error.toString()) swapAlertFactory.getGenericErrorState( expressError = ExpressError.UnknownError, onFailedTxEmailClick = { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index 834289a971..8acb32e4a7 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -12,6 +12,7 @@ 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.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.blockchain.common.transaction.Fee @@ -36,6 +37,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.express.models.ExpressProvider @@ -68,7 +70,8 @@ internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) { Box( modifier = Modifier .weight(1f) - .background(TangemTheme.colors.background.tertiary), + .background(TangemTheme.colors.background.tertiary) + .testTag(TransactionSuccessScreenTestTags.CONTAINER), ) { SuccessContent( sendWithSwapUM = sendWithSwapUM, @@ -162,7 +165,8 @@ private fun AmountBlock( modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) - .padding(12.dp), + .padding(12.dp) + .testTag(TransactionSuccessScreenTestTags.AMOUNT_BLOCK), ) { AccountTitle(accountTitleUM) Row( @@ -212,7 +216,8 @@ private fun FeeBlock(feeSelectorUM: FeeSelectorUM.Content) { .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) - .padding(TangemTheme.dimens.spacing12), + .padding(TangemTheme.dimens.spacing12) + .testTag(TransactionSuccessScreenTestTags.FEE_BLOCK), ) { Text( text = stringResourceSafe(R.string.common_network_fee_title), @@ -260,7 +265,8 @@ private fun DestinationBlock( .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) - .padding(12.dp), + .padding(12.dp) + .testTag(TransactionSuccessScreenTestTags.RECIPIENT_BLOCK), ) { Text( text = stringResourceSafe(R.string.send_recipient), diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverterTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverterTest.kt new file mode 100644 index 0000000000..502f29354c --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverterTest.kt @@ -0,0 +1,143 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.provider.entity.ProviderChooseUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class SwapProviderListItemConverterTest { + + private val provider = ExpressProvider( + providerId = "p1", + name = "Test Provider", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + private val fromCurrency = mockk(relaxed = true).also { + every { it.symbol } returns FROM_SYMBOL + every { it.decimals } returns 18 + } + + private val toCurrency = mockk(relaxed = true).also { + every { it.symbol } returns TO_SYMBOL + every { it.decimals } returns 8 + } + + @Test + fun `GIVEN quote with TooSmallError and amountType To WHEN convert THEN error text uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN quote with TooSmallError and amountType From WHEN convert THEN error text uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN quote with TooBigError and amountType To WHEN convert THEN error text uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN quote with TooBigError and amountType From WHEN convert THEN error text uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN quote with NotEnoughAllowanceError and amountType From WHEN convert THEN error text uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote( + ExpressError.AmountError.NotEnoughAllowanceError(code = 3, amount = BigDecimal("10")), + ) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + private fun buildConverter(amountType: SwapAmountType): SwapProviderListItemConverter { + return SwapProviderListItemConverter( + fromCryptoCurrency = fromCurrency, + toCryptoCurrency = toCurrency, + amountType = amountType, + selectedProvider = provider, + isNeedApplyFCARestrictions = false, + needBestRateBadge = false, + ) + } + + private fun errorQuote(error: ExpressError): SwapQuoteUM.Error = SwapQuoteUM.Error( + provider = provider, + expressError = error, + ) + + private fun assertSymbolUsed(text: TextReference, expectedSymbol: String, otherSymbol: String) { + val res = text as TextReference.Res + val formatted = res.formatArgs.first().toString() + assertThat(formatted).contains(expectedSymbol) + assertThat(formatted).doesNotContain(otherSymbol) + } + + private companion object { + const val FROM_SYMBOL = "ETH" + const val TO_SYMBOL = "BTC" + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverterTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverterTest.kt new file mode 100644 index 0000000000..9b01581b84 --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverterTest.kt @@ -0,0 +1,135 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderState +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +@Suppress("DEPRECATION") +internal class SwapProviderStateConverterTest { + + private val provider = ExpressProvider( + providerId = "p1", + name = "Test Provider", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + private val fromCurrency = mockk(relaxed = true).also { + every { it.symbol } returns FROM_SYMBOL + every { it.decimals } returns 18 + } + + private val toCurrency = mockk(relaxed = true).also { + every { it.symbol } returns TO_SYMBOL + every { it.decimals } returns 8 + } + + @Test + fun `GIVEN error quote TooSmallError and amountType To WHEN convert THEN subtitle uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN error quote TooSmallError and amountType From WHEN convert THEN subtitle uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN error quote TooBigError and amountType To WHEN convert THEN subtitle uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN error quote TooBigError and amountType From WHEN convert THEN subtitle uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN error quote NotEnoughAllowanceError and amountType From WHEN convert THEN subtitle uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote( + ExpressError.AmountError.NotEnoughAllowanceError(code = 3, amount = BigDecimal("10")), + ) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + private fun buildConverter(amountType: SwapAmountType): SwapProviderStateConverter { + return SwapProviderStateConverter( + fromCryptoCurrency = fromCurrency, + toCryptoCurrency = toCurrency, + amountType = amountType, + selectedProvider = provider, + isNeedBestRateBadge = false, + isNeedApplyFCARestrictions = false, + ) + } + + private fun errorQuote(error: ExpressError): SwapQuoteUM.Error = SwapQuoteUM.Error( + provider = provider, + expressError = error, + ) + + private fun assertSymbolUsed(state: SwapProviderState, expectedSymbol: String, otherSymbol: String) { + val content = state as SwapProviderState.Content + val subtitle = content.subtitle as TextReference.Res + val formatted = subtitle.formatArgs.first().toString() + assertThat(formatted).contains(expectedSymbol) + assertThat(formatted).doesNotContain(otherSymbol) + } + + private companion object { + const val FROM_SYMBOL = "ETH" + const val TO_SYMBOL = "BTC" + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolverTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolverTest.kt new file mode 100644 index 0000000000..88ec6fdb10 --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolverTest.kt @@ -0,0 +1,52 @@ +package com.tangem.features.swap.v2.impl.common + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType +import io.mockk.mockk +import org.junit.Test + +internal class AmountErrorCurrencyResolverTest { + + private val from = mockk(relaxed = true) + private val to = mockk(relaxed = true) + + @Test + fun `GIVEN amountType From WHEN resolveAmountErrorCurrency THEN returns fromCryptoCurrency`() { + // GIVEN, WHEN + val result = resolveAmountErrorCurrency( + fromCryptoCurrency = from, + toCryptoCurrency = to, + amountType = SwapAmountType.From, + ) + + // THEN + assertThat(result).isSameInstanceAs(from) + } + + @Test + fun `GIVEN amountType To WHEN resolveAmountErrorCurrency THEN returns toCryptoCurrency`() { + // GIVEN, WHEN + val result = resolveAmountErrorCurrency( + fromCryptoCurrency = from, + toCryptoCurrency = to, + amountType = SwapAmountType.To, + ) + + // THEN + assertThat(result).isSameInstanceAs(to) + } + + @Test + fun `GIVEN amountType null WHEN resolveAmountErrorCurrency THEN returns fromCryptoCurrency`() { + // GIVEN, WHEN + val result = resolveAmountErrorCurrency( + fromCryptoCurrency = from, + toCryptoCurrency = to, + amountType = null, + ) + + // THEN + assertThat(result).isSameInstanceAs(from) + } +} \ No newline at end of file diff --git a/features/swap/CLAUDE.md b/features/swap/CLAUDE.md new file mode 100644 index 0000000000..c4f75d64f1 --- /dev/null +++ b/features/swap/CLAUDE.md @@ -0,0 +1,148 @@ +# Swap Feature + +Token-to-token exchange feature. Users select FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute swaps. + +## Module Structure + +``` +features/swap/ + api/ — Public contracts (SwapComponent, SwapEntryComponent, SwapFeatureToggles) + impl/ — UI, model, navigation, DI, token selection subfeature + domain/ — Business logic (SwapInteractor) + domain models + api/ — Domain interfaces + models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.) + data/ — Repository implementations, Retrofit APIs, Moshi DTOs +``` + +**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency). + +## Key Components + +### SwapComponent (API) +Entry point. `Params` requires `currencyFrom`, `userWalletId`, `screenSource`. Optional: `currencyTo`, `isInitialReverseOrder`, `tangemPayInput`, `preselectedToToken`, `preselectedAccount`. + +### SwapEntryComponent (API) +Gateway component with sealed `Params`: `Story`, `Empty`, `Selected`, `Payment`. Routes to stories or directly to swap based on input type. See `entry/SwapEntryRoute.kt` for route definitions. + +### DefaultSwapComponent (impl) +Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`. + +**Child navigation:** +- `childStack(SwapRoute)` for screen navigation — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)` rendered via `Children` composable with fade animation +- `SlotNavigation` for approval bottom sheet (`GiveApprovalComponent`) +- `SlotNavigation` for fee selector block + +**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`. + +### SwapModel (impl) +`@ModelScoped`, extends `Model()`. The central coordinator — ~1500 lines. + +**Key state:** +- `dataStateStateFlow: MutableStateFlow` — reactive domain data (from/to tokens, pairs, providers, amounts, fees) +- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder` +- `feeSelectorRepository: FeeSelectorRepository` — fee state management +- `stackNavigation: StackNavigation` — stack navigation exposed from `SwapRouter` +- `approvalSlotNavigation: SlotNavigation` — approval bottom sheet + +**Navigation:** +- `SwapRouter` wraps `AppRouter` + `StackNavigation` for screen switching and back navigation +- `swapRouter.openScreen(SwapRoute.SelectToken(isFromDirection))` to push token selection +- `swapRouter.openScreen(SwapRoute.Success)` replaces current with success screen +- `swapRouter.back()` — pops local stack or exits swap via AppRouter + +**Initialization flow (init block):** +1. Subscribes to `chooseTokenBridge.onCurrencyChosen` → `onTokenSelect(result)` +2. Subscribes to `chooseTokenBridge.onClose` → pops slot navigation +3. Checks `ShouldShowStoriesUseCase` → pushes `AppRoute.Stories` if first-time swap +4. Resolves user country for FCA restrictions +5. Loads primary account status, initial currencies, and starts swap pair loading + +**Token selection flow:** +1. User taps FROM or TO card → `onSelectTokenClick(direction)` pushes `SwapRoute.SelectToken(isFromDirection)` to stack +2. Stack creates `ChooseTokenComponent` with appropriate bridge (FROM or TO) +3. `ChooseTokenBridge` communicates selection result via Channel +4. `onTokenSelect(result)` assigns selected token to FROM or TO based on `isFromDirection` + +**Swap execution flow:** +1. `onSwapClick()` — validates state, checks approval, initiates transaction +2. If approval needed → `approvalSlotNavigation.activate(Unit)` +3. On approval done → reloads quotes +4. On swap success → `swapRouter.openScreen(SwapRoute.Success)` + +### StateBuilder (impl) +Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`. + +Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`. + +### SwapRouter (impl) +Wraps `AppRouter` + `StackNavigation`. Handles `openScreen(SwapRoute)` to push/replace stack entries and `back()` with special logic: SelectToken pops local stack, Success exits to screen before SwapCrypto in app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`. + +## Token Selection Subfeature (impl) + +Self-contained within `choosetoken/` package: +- `ChooseTokenComponent` — API with `Params(bridge, settings, analyticsPayload)` +- `ChooseTokenBridge` — Channel-based communication: `onCurrencyChosen`, `onClose`, `onTokenSelected` (legacy), `onNewTokenAdded` (legacy). Has `settingsStateFlow` for dynamic settings. +- `ChooseTokenComponent.Settings` — `SwapFrom` (no market block) vs `SwapTo` (with market block) +- `ChooseTokenResult` — Contains `CryptoCurrencyStatus`, `AccountStatus`, `UserWallet` +- `DefaultChooseTokenComponent` — Has its own `ChooseTokenModel` and optional `AddToPortfolioComponent` bottom sheet slot + +## Domain Layer + +### SwapInteractor +Central domain interface. Methods: +- `getPair(from, to, filterProviderTypes)` → `Either>` +- `findBestQuote(from, to, providers, amount, ...)` → `Map` +- `onSwap(from, to, provider, swapData, amount, fee, ...)` → `SwapTransactionState` +- `loadFeeForSwapTransaction(...)` → `Either` +- `getInitialCurrencyToSwap(accountStatusList, fromUserWallet, isReverse)` → `AccountCryptoCurrencyStatus?` +- `getTokenBalance(token)` → `SwapAmount` + +### Key Domain Models +- `SwapPairLeast` — from/to token info + providers list +- `SwapProvider` — providerId, name, type (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links +- `SwapState` — sealed: `QuotesLoadedState`, `SwapError`, `EmptyAmountState` +- `SwapCurrencyStatus` — wraps `CryptoCurrencyStatus` + `UserWallet` + `Account` +- `SwapAmount` — value + decimals pair +- `SwapDataModel` — quote result with transaction data + +## DI Modules + +| Module | Scope | Bindings | +|--------|-------|----------| +| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` | +| `SwapModelModule` | ModelComponent | `SwapModel` into model map | +| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` | +| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` | +| `SwapSingletonModule` | Singleton | `AmountFormatter` | + +## UI Layer + +- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee) +- `SwapSuccessScreen` — post-swap success with transaction details +- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning +- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input +- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick` + +## Navigation Summary + +``` +AppRouter (global) + └─ AppRoute.Swap → DefaultSwapComponent + ├─ childStack(SwapRoute) + │ ├─ SwapRoute.Main → SwapMainChild (renders SwapScreen) + │ ├─ SwapRoute.Success → SwapSuccessChild (renders SwapSuccessScreen) + │ └─ SwapRoute.SelectToken → ChooseTokenComponent (FROM or TO bridge) + ├─ SlotNavigation (Approval) + │ └─ GiveApprovalComponent (bottom sheet) + └─ SlotNavigation + └─ SwapFeeSelectorBlockComponent (inline fee block) +``` + +## Build Commands + +```bash +./gradlew :features:swap:impl:compileDebugKotlin +./gradlew :features:swap:api:compileDebugKotlin +./gradlew :features:swap:domain:compileDebugKotlin +./gradlew :features:swap:impl:detekt +``` \ No newline at end of file diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index ed9144cac3..6b8f708a35 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -2,23 +2,18 @@ package com.tangem.features.swap import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import java.math.BigDecimal interface SwapComponent : ComposableContentComponent { data class Params( - val currencyFrom: CryptoCurrency, - val currencyTo: CryptoCurrency? = null, val userWalletId: UserWalletId, - val isInitialReverseOrder: Boolean = false, + val cryptoCurrency: CryptoCurrency? = null, val screenSource: String, + val currencyPosition: CurrencyPosition = CurrencyPosition.ANY, val tangemPayInput: TangemPayInput? = null, - val preselectedToToken: CryptoCurrencyStatus? = null, - val preselectedAccount: Account? = null, ) { data class TangemPayInput( val cryptoAmount: BigDecimal, @@ -26,6 +21,16 @@ interface SwapComponent : ComposableContentComponent { val depositAddress: String, val isWithdrawal: Boolean, ) + + /** Preferred position of the pre-selected currency on the swap screen. */ + enum class CurrencyPosition { + /** Force-place as the FROM (send) currency. */ + FROM, + /** Force-place as the TO (receive) currency. */ + TO, + /** Auto-determine position based on availability and balance. */ + ANY, + } } interface Factory : ComponentFactory diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index d782e276ca..e0fe076fab 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,5 +1,3 @@ package com.tangem.features.swap -interface SwapFeatureToggles { - val isMarketListFeatureEnabled: Boolean -} \ No newline at end of file +interface SwapFeatureToggles \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index e27cb8705f..f2c9cfd2af 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -23,6 +23,7 @@ import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.api.SwapRepository @@ -226,7 +227,8 @@ internal class DefaultSwapRepository( } override suspend fun getExchangeStatus( - userWallet: UserWallet, + userWallet: UserWallet?, + userWalletId: UserWalletId, txId: String, ): Either { return withContext(coroutineDispatcher.io) { @@ -236,7 +238,7 @@ internal class DefaultSwapRepository( exchangeStatusConverter.convert( tangemExpressApi .getExchangeStatus( - userWalletId = userWallet.walletId.stringValue, + userWalletId = userWalletId.stringValue, refCode = ExpressUtils.getRefCode( userWallet = userWallet, appPreferencesStore = appPreferencesStore, @@ -247,7 +249,7 @@ internal class DefaultSwapRepository( ) }, catch = { exception -> - TangemLogger.e("getExchangeStatus error: $exception") + TangemLogger.e("getExchangeStatus error", exception) raise(UnknownError(exception.message)) }, ) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 1d102e458e..fe9a0e7d93 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -41,7 +41,8 @@ internal class DefaultSwapTransactionRepository( private val userTokensResponseFactory = UserTokensResponseFactory() override suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -62,7 +63,8 @@ internal class DefaultSwapTransactionRepository( val tokenTransactions = savedTransactions ?.firstOrNull { savedTx -> savedTx.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) @@ -76,7 +78,8 @@ internal class DefaultSwapTransactionRepository( mutablePreferences.setObject( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, value = savedTransactions?.updateList( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -84,7 +87,8 @@ internal class DefaultSwapTransactionRepository( transactions = tokenTransactions, ) ?: listOf( converter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -111,13 +115,13 @@ internal class DefaultSwapTransactionRepository( ) { savedTransactions, txStatuses, accountList -> val currencyToTxs = savedTransactions?.filter { savedTx -> - val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue + val isUserWallet = savedTx.toUserWalletId == userWallet.walletId.stringValue val isToCurrency = savedTx.toCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && isToCurrency } val currencyFromTxs = savedTransactions?.filter { savedTx -> - val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue + val isUserWallet = savedTx.fromUserWalletId == userWallet.walletId.stringValue val isFromCurrency = savedTx.fromCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && isFromCurrency } @@ -227,18 +231,21 @@ internal class DefaultSwapTransactionRepository( } private fun SavedSwapTransactionListModelInner.checkId( - checkUserWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCurrencyId: CryptoCurrency.ID, toCurrencyId: CryptoCurrency.ID, ): Boolean { - return userWalletId == checkUserWalletId.stringValue && + return this.fromUserWalletId == fromUserWalletId.stringValue && + this.toUserWalletId == toUserWalletId.stringValue && toCryptoCurrencyId == toCurrencyId.value && fromCryptoCurrencyId == fromCurrencyId.value } @Suppress("LongParameterList") private fun List.updateList( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -247,7 +254,8 @@ internal class DefaultSwapTransactionRepository( ): List { return addOrReplace( item = converter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -256,7 +264,8 @@ internal class DefaultSwapTransactionRepository( ), predicate = { savedTx -> savedTx.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt index 12dc1705f0..cbb6bd7b04 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt @@ -9,7 +9,7 @@ class LeastTokenInfoConverter : Converter { override fun convert(value: CryptoCurrency): LeastTokenInfo { return LeastTokenInfo( contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = value.network.backendId, + network = value.network.rawId, ) } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index 2b50f90d14..10b480b9a1 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -28,7 +28,8 @@ internal class SavedSwapTransactionListConverter( private val userTokensResponseFactory = UserTokensResponseFactory() override fun convert(value: SavedSwapTransactionListModel) = SavedSwapTransactionListModelInner( - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( @@ -98,7 +99,8 @@ internal class SavedSwapTransactionListConverter( val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency) tx.copy(status = statusWithRefundCurrency) }, - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, @@ -117,14 +119,16 @@ internal class SavedSwapTransactionListConverter( @Suppress("LongParameterList") fun default( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, toAccount: Account?, tokenTransactions: List, ) = SavedSwapTransactionListModelInner( - userWalletId = userWalletId.stringValue, + fromUserWalletId = fromUserWalletId.stringValue, + toUserWalletId = toUserWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( @@ -162,8 +166,12 @@ internal class SavedSwapTransactionListConverter( } private fun findAccountByDerivationIndex(accountList: AccountList?, derivationIndex: DerivationIndex?): Account? { - return accountList?.accounts?.asSequence()?.filterIsInstance() - ?.firstOrNull { it.derivationIndex == derivationIndex } + val accounts = accountList?.accounts ?: return null + + return accounts.asSequence() + .filterIsInstance() + .firstOrNull { it.derivationIndex == derivationIndex } + ?: accounts.firstOrNull { it is Account.Payment }.takeIf { derivationIndex == null } } private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index d0baa14afb..5a567049e0 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -20,6 +20,8 @@ dependencies { kapt(deps.hilt.kapt) /** Domain */ + implementation(projects.domain.swap.models) + implementation(projects.domain.swap) implementation(projects.domain.appCurrency) implementation(projects.domain.appCurrency.models) implementation(projects.domain.card) @@ -38,6 +40,7 @@ dependencies { implementation(projects.domain.express.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.visa) implementation(projects.domain.visa.models) implementation(projects.features.swap.domain.api) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt deleted file mode 100644 index e8d4a35b72..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency -import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress -import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse -import com.tangem.utils.extensions.orZero - -internal class DefaultInitialToCurrencyResolver( - private val swapTransactionRepository: SwapTransactionRepository, -) : InitialToCurrencyResolver { - - override suspend fun tryGetFromCache( - userWallet: UserWallet, - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? { - val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null - - return if (id != initialCryptoCurrency.id.value) { - val group = state.getGroupWithReverse(isReverseFromTo) - - group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id } - } - } else { - null - } - } - - override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? { - val group = state.getGroupWithReverse(isReverseFromTo) - return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.maxByOrNull { swapAccountCurrency -> - swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount - .takeIf { swapAccountCurrency.isAvailable } - .orZero() - } - } - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt deleted file mode 100644 index da6436ee57..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency -import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress - -interface InitialToCurrencyResolver { - - suspend fun tryGetFromCache( - userWallet: UserWallet, - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? - - fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 98165323aa..98d5c4d9e1 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -2,87 +2,58 @@ package com.tangem.feature.swap.domain import arrow.core.Either import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressOperationType -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.PermissionOptions -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.SwapTransactionState +import com.tangem.feature.swap.domain.models.ui.TxFee import java.math.BigDecimal interface SwapInteractor { - suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress + suspend fun getPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + ): Either> - /** - * Gives permission to swap, this starts scan card process - * - * @param networkId network in which selected token - * @param permissionOptions data to give permissions - */ - @Throws(IllegalStateException::class) - suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): SwapTransactionState + suspend fun findProvidersForPairWithCheck( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List + + fun findProvidersForPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List - /** - * Find best quote for given tokens to swap - * under the hood calls different methods to receive data, depends on permission for given token - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param providers list of providers to find quote - * @param amountToSwap amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param txFeeSealedState selected fee to swap - * @return - */ @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun findBestQuote( - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, txFeeSealedState: TxFeeSealedState, ): Map - /** - * Starts swap transaction, perform sign transaction - * - * @param swapProvider swap provider to use - * @param swapData tx data to swap, contains data to sign - * @param currencyToSend crypto currency to send - * @param currencyToGet crypto currency to get - * @param fromAccount account from which swap will be made - * @param toAccount account to which receive token - * @param amountToSwap amount to swap - * @param includeFeeInAmount flag to include fee in amount - * @param fee for tx (can be null only for tangem pay withdrawal) - * @param expressOperationType type of express operation - - * @return [SwapTransactionState] - */ @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun onSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, swapProvider: SwapProvider, swapData: SwapDataModel?, - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee?, @@ -90,14 +61,6 @@ interface SwapInteractor { isTangemPayWithdrawal: Boolean, ): SwapTransactionState - // suspend fun updateQuotesStateWithSelectedFee( - // state: SwapState.QuotesLoadedState, - // selectedFee: FeeType, - // fromToken: CryptoCurrencyStatus, - // amountToSwap: String, - // reduceBalanceBy: BigDecimal, - // ): SwapState.QuotesLoadedState - /** * Returns token in wallet balance * @@ -105,27 +68,12 @@ interface SwapInteractor { */ fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount - /** - * Returns initial currency to swap as AccountSwapCurrency - * - * @param initialCryptoCurrency initial currency selected to swap - * @param state current tokens data state - * @param isReverseFromTo flag indicating the direction of the swap - */ - suspend fun getInitialCurrencyToSwap( - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? - - suspend fun getNativeToken(network: Network): CryptoCurrency + suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency @Suppress("LongParameterList") suspend fun storeSwapTransaction( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -135,51 +83,19 @@ interface SwapInteractor { averageDuration: Int? = null, ) - /** - * Loads fee for swap transaction - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param amount amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param selectedFeeToken selected token to pay fee or null to pay fee with coin - */ - @Suppress("LongParameterList") suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, selectedFeeToken: CryptoCurrencyStatus?, ): Either - /** - * Loads fee for swap transaction - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param amount amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - */ - @Suppress("LongParameterList") suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, ): Either - - interface Factory { - fun create(selectedWalletId: UserWalletId): SwapInteractor - } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 3d6d5e9e1a..35e943bda2 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -19,8 +19,6 @@ import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer -import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -28,18 +26,18 @@ import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.exchange.RampStateManager -import com.tangem.domain.express.models.ExpressOperationType -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.express.models.* import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayWithdrawExchangeState import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapTxType +import com.tangem.domain.swap.usecase.GetSwapPairUseCase import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer @@ -58,7 +56,6 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -68,16 +65,17 @@ import com.tangem.feature.swap.domain.models.ui.* import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.* +import jakarta.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.supervisorScope import java.math.BigDecimal import java.math.BigInteger import java.math.RoundingMode @Suppress("LargeClass", "LongParameterList") -internal class SwapInteractorImpl @AssistedInject constructor( +internal class SwapInteractorImpl @Inject constructor( private val repository: SwapRepository, private val allowPermissionsHandler: AllowPermissionsHandler, private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, @@ -85,7 +83,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val createTransactionUseCase: CreateTransactionUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, - private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val quotesRepository: QuotesRepository, private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, @@ -94,7 +91,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val appCurrencyRepository: AppCurrencyRepository, private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, @@ -103,16 +99,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, - private val getUserWalletUseCase: GetUserWalletUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, private val amountFormatter: AmountFormatter, private val rampStateManager: RampStateManager, - private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val walletManagersFacade: WalletManagersFacade, private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase, - @Assisted private val userWalletId: UserWalletId, + private val getSwapPairUseCase: GetSwapPairUseCase, ) : SwapInteractor { private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) { @@ -121,201 +115,79 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val hundredPercent = BigInteger("100") - private val userWallet - get() = getUserWalletUseCase(userWalletId).getOrElse { - error("Failed to get user wallet") - } - - override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - return getAccountCurrencyTokensDataState(currency) - } - - private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( - SingleAccountStatusListProducer.Params(userWalletId), - )?.accountStatuses.orEmpty().filterCryptoPortfolio() - - val walletAccountCurrencyStatusesExceptInitial: Map> = - walletAccountCurrencyStatuses.mapNotNull { accountStatus -> - val filteredCurrencies = accountStatus.flattenCurrencies().filterCurrencies(currency) - - if (filteredCurrencies.isNotEmpty()) { - accountStatus.account to filteredCurrencies - } else { - null + override suspend fun getPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + ): Either> { + return getSwapPairUseCase( + primarySwapCurrencyStatus = fromSwapCurrencyStatus, + secondarySwapCurrencyStatus = toSwapCurrencyStatus, + filterProviderTypes = filterProviderTypes.map { type -> + // Temporary solution until domain layer is migrated + when (type) { + ExchangeProviderType.DEX -> ExpressProviderType.DEX + ExchangeProviderType.CEX -> ExpressProviderType.CEX + ExchangeProviderType.DEX_BRIDGE -> ExpressProviderType.DEX_BRIDGE } - }.toMap() - - if (walletAccountCurrencyStatusesExceptInitial.isEmpty()) { - return TokensDataStateExpress.EMPTY - } - - val pairsLeast = getPairs( - userWallet = userWallet, - initialCurrency = LeastTokenInfo( - contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.backendId, - ), - currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus -> - accountStatus.value.map { it.currency } }, - ) - - return TokensDataStateExpress( - fromGroup = getToCurrenciesGroup( - currency = currency, - leastPairs = pairsLeast.pairs, - cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, - tokenInfoForFilter = { it.to }, - tokenInfoForAvailable = { it.from }, - ), - toGroup = getToCurrenciesGroup( - currency = currency, - leastPairs = pairsLeast.pairs, - cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, - tokenInfoForFilter = { it.from }, - tokenInfoForAvailable = { it.to }, - ), - allProviders = pairsLeast.allProviders, - ) - } - - private fun List.filterCurrencies(currency: CryptoCurrency) = this.filter { status -> - val isDifferentCurrency = status.currency.network.backendId != currency.network.backendId || - status.currency.getContractAddress() != currency.getContractAddress() - - val hasValidStatus = - status.value is CryptoCurrencyStatus.Loaded || status.value is CryptoCurrencyStatus.NoAccount - val isNotCustomToken = !status.currency.isCustom - - hasValidStatus && isDifferentCurrency && isNotCustomToken - } - - private suspend fun getToCurrenciesGroup( - currency: CryptoCurrency, - leastPairs: List, - cryptoCurrenciesList: Map>, - tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo, - tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, - ): CurrenciesGroup { - val filteredPairs = leastPairs.filter { pair -> - tokenInfoForFilter(pair).contractAddress == currency.getContractAddress() && - tokenInfoForFilter(pair).network == currency.network.backendId - } - - val accountCurrencyList = cryptoCurrenciesList.mapNotNull { (accountEntry, currencyStatusList) -> - val cryptoPortfolio = accountEntry as? Account.CryptoPortfolio ?: return@mapNotNull null - - AccountSwapAvailability( - account = cryptoPortfolio, - currencyList = currencyStatusList.map { currencyStatus -> - val providers = findProvidersForPair( - cryptoCurrencyStatuses = currencyStatus, - swapPairsLeastList = filteredPairs, - tokenInfoForAvailable = tokenInfoForAvailable, - ) - val isUnavailable = providers.isNullOrEmpty() - AccountSwapCurrency( - isAvailable = !isUnavailable, - account = accountEntry, - cryptoCurrencyStatus = currencyStatus, - providers = providers.orEmpty(), - ) - }, - ) - } - - return CurrenciesGroup( - available = emptyList(), - unavailable = emptyList(), - accountCurrencyList = accountCurrencyList, - isAfterSearch = false, - ) - } - - private suspend fun findProvidersForPair( - cryptoCurrencyStatuses: CryptoCurrencyStatus, - swapPairsLeastList: List, - tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, - ): List? { - val requirements = getAssetRequirementsUseCase.invoke(userWalletId, cryptoCurrencyStatuses.currency).getOrNull() - val isAvailableForSwap = rampStateManager.checkAssetRequirements(requirements) - - return swapPairsLeastList.firstNotNullOfOrNull { pair -> - val listTokenInfo = tokenInfoForAvailable(pair) - if (cryptoCurrencyStatuses.currency.network.backendId == listTokenInfo.network && - cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress && - isAvailableForSwap - ) { - pair.providers - } else { - null + swapTxType = SwapTxType.Swap, + ).map { pairs -> + pairs.map { pair -> + SwapPairLeast( + from = LeastTokenInfo( + contractAddress = pair.from.currency.getContractAddress(), + network = pair.from.currency.network.rawId, + ), + to = LeastTokenInfo( + contractAddress = pair.to.currency.getContractAddress(), + network = pair.to.currency.network.rawId, + ), + providers = pair.providers.map { provider -> + provider.toSwapProvider() + }, + ) } } } - private fun CryptoCurrency.getContractAddress(): String { - return when (this) { - is CryptoCurrency.Token -> this.contractAddress - is CryptoCurrency.Coin -> "0" - } + override fun findProvidersForPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List { + return pairs.firstOrNull { pair -> + pair.from.network == fromSwapCurrencyStatus.currency.network.rawId && + pair.from.contractAddress == fromSwapCurrencyStatus.currency.getContractAddress() && + pair.to.network == toSwapCurrencyStatus.currency.network.rawId + pair.to.contractAddress == toSwapCurrencyStatus.currency.getContractAddress() + }?.providers.orEmpty() } - private suspend fun getPairs( - userWallet: UserWallet, - initialCurrency: LeastTokenInfo, - currenciesList: List, - ): PairsWithProviders { - return repository.getPairs( - userWallet = userWallet, - initialCurrency = initialCurrency, - currencyList = currenciesList, + override suspend fun findProvidersForPairWithCheck( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List { + val requirements = getAssetRequirementsUseCase.invoke( + fromSwapCurrencyStatus.userWalletId, + fromSwapCurrencyStatus.currency, + ).getOrNull() + + if (!rampStateManager.checkAssetRequirements(requirements)) { + return emptyList() + } + + return findProvidersForPair( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + pairs = pairs, ) } - override suspend fun givePermissionToSwap( - networkId: String, - permissionOptions: PermissionOptions, - ): SwapTransactionState { - val amount = permissionOptions.approveData.fromTokenAmount.takeIf { - permissionOptions.approveType == SwapApproveType.LIMITED - } - - val approveTransaction = createApprovalTransactionUseCase( - fee = permissionOptions.txFee.fee, - userWalletId = userWalletId, - cryptoCurrencyStatus = permissionOptions.fromTokenStatus, - amount = amount?.value, - contractAddress = permissionOptions.forTokenContractAddress, - spenderAddress = permissionOptions.spenderAddress, - ).getOrElse { error -> - TangemLogger.e("Failed to create approveTransaction", error) - return SwapTransactionState.Error.UnknownError - } - - val result = sendTransactionUseCase( - txData = approveTransaction, - userWallet = userWallet, - network = permissionOptions.fromTokenStatus.currency.network, - ) - return result.fold( - ifRight = { hash -> - allowPermissionsHandler.addAddressToInProgress(permissionOptions.forTokenContractAddress) - SwapTransactionState.TxSent( - txHash = hash, - timestamp = System.currentTimeMillis(), - ) - }, - ifLeft = { SwapTransactionState.Error.TransactionError(it) }, - ) - } - - @Suppress("LongMethod") override suspend fun findBestQuote( - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -324,85 +196,65 @@ internal class SwapInteractorImpl @AssistedInject constructor( TangemLogger.i( """ Find the best quote - |- fromToken: $fromToken - |- fromAccount: $fromAccount - |- toToken: $toToken - |- toAccount: $toAccount + |- fromSwapCurrencyStatus: + |---- walletId: ${fromSwapCurrencyStatus.userWalletId} + |---- accountId: ${fromSwapCurrencyStatus.account.accountId} + |---- currencyId: ${fromSwapCurrencyStatus.currency.id} + |- toSwapCurrencyStatus: $toSwapCurrencyStatus + |---- walletId: ${toSwapCurrencyStatus.userWalletId} + |---- accountId: ${toSwapCurrencyStatus.account.accountId} + |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- providers: $providers |- amountToSwap: $amountToSwap |- selectedFee: $txFeeSealedState """.trimIndent(), + shouldSanitize = false, ) val amountDecimal = toBigDecimalOrNull(amountToSwap) if (amountDecimal == null || amountDecimal.signum() == 0) { return providers.associateWith { createEmptyAmountState() } } - val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) - val networkId = fromToken.currency.network.backendId - + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null) return supervisorScope { providers.map { provider -> async { - try { - when (provider.type) { - ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - if (isSolana(networkId)) { - manageDexSolana( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - provider = provider, - txFeeSealedState = txFeeSealedState, - amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - expressOperationType = ExpressOperationType.SWAP, - ) - } else { - manageDex( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - provider = provider, - txFeeSealedState = txFeeSealedState, - amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - expressOperationType = ExpressOperationType.SWAP, - ) - } - } - ExchangeProviderType.CEX -> { - manageCex( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + when (provider.type) { + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + if (isSolana(fromSwapCurrencyStatus.currency.network.rawId)) { + manageDexSolana( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, + ) + } else { + manageDex( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + txFeeSealedState = txFeeSealedState, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, ) } } - } catch (e: Throwable) { - if (e is CancellationException) { - throw e + ExchangeProviderType.CEX -> { + manageCex( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + txFeeSealedState = txFeeSealedState, + ) } - TangemLogger.e("Failed to find quote for provider: ${provider.providerId}", e) - provider to createSwapErrorWith( - fromToken = fromToken, - fromAccount = fromAccount, - amount = amount, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - expressDataError = ExpressDataError.UnknownError, - ) } } }.awaitAll().toMap() @@ -411,46 +263,42 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod") private suspend fun manageDex( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, ): Pair { - if (fromToken.value.yieldSupplyStatus?.isActive == true) { + if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) { return provider to produceDexSwapDataError( error = ExpressDataError.DexActiveSupplyError, - fromToken = fromToken, - fromAccount = fromAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, ) } val maybeQuotes = repository.findBestQuote( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, - toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.backendId, + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) - val fromTokenAddress = getTokenAddress(fromToken.currency) + val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency) val isAllowedToSpend = maybeQuotes.fold( ifRight = { quotes -> quotes.allowanceContract?.let { allowanceContract -> getAllowanceInfoUseCase( - userWalletId = userWalletId, - cryptoCurrency = fromToken.currency, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrency = fromSwapCurrencyStatus.currency, spenderAddress = allowanceContract, requiredAmount = amount.value, ).getOrNull() is AllowanceInfo.Enough @@ -461,16 +309,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) - cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currency = fromToken.currency) + cryptoCurrencyBalanceFetcher( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = fromSwapCurrencyStatus.currency, + ) } return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { provider to loadDexSwapData( provider = provider, - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, @@ -480,11 +328,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = maybeQuotes, amount = amount, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, @@ -494,11 +339,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageDexSolana( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -506,14 +348,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( expressOperationType: ExpressOperationType, ): Pair { val maybeQuotes = repository.findBestQuote( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, - toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.backendId, + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) @@ -521,11 +363,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( return if (isBalanceWithoutFeeEnough && maybeQuotes.isRight()) { provider to loadDexSwapData( provider = provider, - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, @@ -535,11 +374,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = maybeQuotes, amount = amount, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, isBalanceWithoutFeeEnough = false, txFeeSealedState = txFeeSealedState, @@ -549,11 +385,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageCex( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -561,13 +394,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( txFeeSealedState: TxFeeSealedState, ): Pair { return provider to loadCexQuoteData( - networkId = networkId, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, @@ -576,7 +406,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageWarnings( - fromTokenStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealed: TxFeeSealedState?, includeFeeInAmount: IncludeFeeInAmount, @@ -600,7 +430,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } ?: BigDecimal.ZERO val balanceAfterTransaction = getCoinBalanceAfterTransaction( - fromTokenStatus = fromTokenStatus, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, includeFeeInAmount = includeFeeInAmount, fee = fee, @@ -611,12 +441,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount } val feePaidCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = fromTokenStatus, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).getOrNull() val currencyCheck = getCurrencyCheckUseCase( - userWalletId = userWalletId, - currencyStatus = fromTokenStatus, + userWalletId = fromSwapCurrencyStatus.userWalletId, + currencyStatus = fromSwapCurrencyStatus.status, feeCurrencyStatus = feePaidCurrencyStatus, amount = amountToRequest.value, fee = fee, @@ -627,14 +457,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getCoinBalanceAfterTransaction( - fromTokenStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, fee: BigDecimal, ): BigDecimal? { - return when (fromTokenStatus.currency) { + return when (fromSwapCurrencyStatus.currency) { is CryptoCurrency.Coin -> { - val statusValue = fromTokenStatus.value as? CryptoCurrencyStatus.Loaded + val statusValue = fromSwapCurrencyStatus.status.value as? CryptoCurrencyStatus.Loaded when (includeFeeInAmount) { is IncludeFeeInAmount.Included -> { statusValue?.let { it.amount - includeFeeInAmount.amountSubtractFee.value - fee } @@ -646,15 +476,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } is CryptoCurrency.Token -> { - val feePaidCurrency = getFeePaidCurrency( - currency = fromTokenStatus.currency, - ) + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) when (feePaidCurrency) { FeePaidCurrency.Coin -> { val nativeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = fromTokenStatus.currency.network.backendId, - derivationPath = fromTokenStatus.currency.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromSwapCurrencyStatus.currency.network.rawId, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) nativeBalance - fee @@ -666,12 +494,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageTransactionValidationWarnings( - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, - userWalletId: UserWalletId, ): Throwable? { - val currency = fromToken.currency + val currency = fromSwapCurrencyStatus.currency val blockchain = currency.network.toBlockchain() // Stellar validation removed because swap uses destination = "0" and throws an error if (blockchain == Blockchain.Stellar) { @@ -696,11 +523,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) val result = validateTransactionUseCase( - amount = amount.value.convertToSdkAmount(fromToken), + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), fee = fee, memo = null, - destination = getTokenAddress(fromToken.currency), - userWalletId = userWalletId, + destination = getTokenAddress(fromSwapCurrencyStatus.currency), + userWalletId = fromSwapCurrencyStatus.userWalletId, network = currency.network, ).leftOrNull() @@ -709,12 +536,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("NullableToStringCall") override suspend fun onSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, swapProvider: SwapProvider, swapData: SwapDataModel?, - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee?, @@ -726,15 +551,22 @@ internal class SwapInteractorImpl @AssistedInject constructor( Swap |- swapProvider: $swapProvider |- swapData: $swapData - |- currencyToSend: $currencyToSend - |- currencyToGet: $currencyToGet + |- fromSwapCurrencyStatus: + |---- walletId: ${fromSwapCurrencyStatus.userWalletId} + |---- accountId: ${fromSwapCurrencyStatus.account.accountId} + |---- currencyId: ${fromSwapCurrencyStatus.currency.id} + |- toSwapCurrencyStatus: $toSwapCurrencyStatus + |---- walletId: ${toSwapCurrencyStatus.userWalletId} + |---- accountId: ${toSwapCurrencyStatus.account.accountId} + |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- amountToSwap: $amountToSwap |- includeFeeInAmount: $includeFeeInAmount |- fee: $fee """.trimIndent(), + shouldSanitize = false, ) - val userWallet = userWallet + val userWallet = fromSwapCurrencyStatus.userWallet if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.DemoMode } @@ -742,17 +574,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( return when (swapProvider.type) { ExchangeProviderType.CEX -> { val amountDecimal = toBigDecimalOrNull(amountToSwap) - val amount = SwapAmount(requireNotNull(amountDecimal), currencyToSend.currency.decimals) + val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals) val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee } else { amount } onSwapCex( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amountToSwapWithFee, txFee = fee, swapProvider = swapProvider, @@ -761,15 +591,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - val networkId = currencyToSend.currency.network.backendId + val networkId = fromSwapCurrencyStatus.currency.network.rawId if (isSolana(networkId)) { onSwapSolanaDex( provider = swapProvider, swapData = requireNotNull(swapData), - currencyToSendStatus = currencyToSend, - currencyToGetStatus = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amountToSwap = amountToSwap, ) } else { @@ -777,10 +605,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( onSwapDex( provider = swapProvider, swapData = requireNotNull(swapData), - currencyToSendStatus = currencyToSend, - currencyToGetStatus = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, txFee = fee, amountToSwap = amountToSwap, ) @@ -790,41 +616,41 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun onSwapDex( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, txFee: TxFee, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } - val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dataToSign = dexTransaction.txData - val amountToSend = createNativeAmountForDex(txValue, currencyToSendStatus.currency.network) + val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) val txData = createTransactionUseCase( amount = amountToSend, fee = txFee.fee, memo = null, destination = swapData.transaction.txTo, - userWalletId = userWalletId, - network = currencyToSendStatus.currency.network, - txExtras = createDexTxExtras(dataToSign, currencyToSendStatus.currency.network, txFee.fee.getGasLimit()), + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = toSwapCurrencyStatus.currency.network, + txExtras = createDexTxExtras( + dataToSign, + fromSwapCurrencyStatus.currency.network, + txFee.fee.getGasLimit(), + ), ).getOrElse { error -> TangemLogger.e("Failed to create swap dex tx data", error) return SwapTransactionState.Error.UnknownError } return handleSwapResult( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, swapData = swapData, - currencyToSendStatus = currencyToSendStatus, - currencyToGetStatus = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, amount = amount, txData = txData, payInAddress = getPayoutAddress(txData), @@ -834,26 +660,22 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun onSwapSolanaDex( provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amountToSwap: String, ): SwapTransactionState { val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txDataBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" } - val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) val compiledTransaction = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(Base64.decode(txDataBase64, Base64.NO_WRAP)), ) return handleSwapResult( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, swapData = swapData, - currencyToSendStatus = currencyToSendStatus, - currencyToGetStatus = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, amount = amount, txData = compiledTransaction, payInAddress = swapData.transaction.txTo, @@ -861,61 +683,55 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun handleSwapResult( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amount: SwapAmount, txData: TransactionData, payInAddress: String, ): SwapTransactionState { val result = sendTransactionUseCase( txData = txData, - userWallet = userWallet, - network = currencyToSendStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, + network = fromSwapCurrencyStatus.currency.network, ) return result.fold( ifRight = { txHash -> - val networkAddress = currencyToSendStatus.value.networkAddress + val networkAddress = fromSwapCurrencyStatus.status.value.networkAddress val fromAddress = networkAddress?.defaultAddress?.value.orEmpty() repository.exchangeSent( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, txId = swapData.transaction.txId, - fromNetwork = currencyToSendStatus.currency.network.backendId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, fromAddress = fromAddress, payInAddress = payInAddress, txHash = txHash, payInExtraId = swapData.transaction.txExtraId, ) - if (provider.type == ExchangeProviderType.DEX_BRIDGE) { - val timestamp = System.currentTimeMillis() - storeSwapTransaction( - currencyToSend = currencyToSendStatus, - currencyToGet = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, - amount = amount, - swapProvider = provider, - swapDataModel = swapData, - timestamp = timestamp, - ) - } - storeLastCryptoCurrencyId(currencyToGetStatus.currency) + val timestamp = System.currentTimeMillis() + storeSwapTransaction( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amount = amount, + swapProvider = provider, + swapDataModel = swapData, + timestamp = timestamp, + ) + storeLastCryptoCurrencyId(fromSwapCurrencyStatus) SwapTransactionState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSendStatus.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( swapData.toTokenAmount, - currencyToGetStatus.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = swapData.toTokenAmount.value, txHash = txHash, - timestamp = System.currentTimeMillis(), + timestamp = timestamp, ) }, ifLeft = { SwapTransactionState.Error.TransactionError(it) }, @@ -932,35 +748,33 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod", "CanBeNonNullable") private suspend fun onSwapCex( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFee: TxFee?, swapProvider: SwapProvider, expressOperationType: ExpressOperationType, isTangemPayWithdrawal: Boolean, ): SwapTransactionState { - val fromNetworkAddress = currencyToSend.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = currencyToGet.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() val exchangeData = repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = currencyToSend.currency.getContractAddress(), - fromNetwork = currencyToSend.currency.network.backendId, - toContractAddress = currencyToGet.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = fromAddress, - toNetwork = currencyToGet.currency.network.backendId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = currencyToGet.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = swapProvider.providerId, rateType = RateType.FLOAT, expressOperationType = expressOperationType, toAddress = toAddress, - refundAddress = currencyToSend.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, refundExtraId = null, // currently always null, ).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) } @@ -968,26 +782,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError if (isTangemPayWithdrawal) { - val networkAddress = currencyToSend.value.networkAddress return SwapTransactionState.TangemPayWithdrawalData( cryptoAmount = amount.value, - cryptoCurrencyId = requireNotNull(currencyToSend.currency.id.rawCurrencyId), + cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId), cexAddress = exchangeDataCex.txTo, fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSend.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( exchangeData.toTokenAmount, - currencyToGet.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -997,26 +808,26 @@ internal class SwapInteractorImpl @AssistedInject constructor( ), exchangeData = TangemPayWithdrawExchangeState( txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.backendId, - fromAddress = networkAddress?.defaultAddress?.value.orEmpty(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(), payInAddress = exchangeData.transaction.txTo, payInExtraId = exchangeDataCex.txExtraId, ), ) } - val userWallet = userWallet + val userWallet = fromSwapCurrencyStatus.userWallet if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.Error.UnknownError } val fee = requireNotNull(txFee) val txData = createTransferTransactionUseCase( - amount = amount.value.convertToSdkAmount(currencyToSend), + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), fee = fee.fee, memo = exchangeDataCex.txExtraId, destination = exchangeDataCex.txTo, - userWalletId = userWalletId, - network = currencyToSend.currency.network, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromSwapCurrencyStatus.currency.network, ).getOrElse { error -> TangemLogger.e("Failed to create swap CEX tx data", error) return SwapTransactionState.Error.UnknownError @@ -1040,7 +851,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( sendTransactionUseCase( txData = txData, userWallet = userWallet, - network = currencyToSend.currency.network, + network = fromSwapCurrencyStatus.currency.network, ) } } @@ -1048,12 +859,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( sendTransactionUseCase( txData = txData, userWallet = userWallet, - network = currencyToSend.currency.network, + network = fromSwapCurrencyStatus.currency.network, ) } } - val cexNetworkAddress = currencyToSend.value.networkAddress + val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() return result.fold( ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) }, @@ -1061,7 +872,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.exchangeSent( userWallet = userWallet, txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.backendId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, fromAddress = cexFromAddress, payInAddress = getPayoutAddress(txData), txHash = txHash, @@ -1070,10 +881,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( val timestamp = System.currentTimeMillis() val txExternalUrl = exchangeDataCex.externalTxUrl storeSwapTransaction( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -1081,16 +890,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( txExternalUrl = txExternalUrl, txExternalId = exchangeDataCex.externalTxId, ) - storeLastCryptoCurrencyId(currencyToGet.currency) + storeLastCryptoCurrencyId(toSwapCurrencyStatus) SwapTransactionState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSend.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( exchangeData.toTokenAmount, - currencyToGet.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, txHash = txHash, @@ -1102,10 +911,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun storeSwapTransaction( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -1115,11 +922,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( averageDuration: Int?, ) { swapTransactionRepository.storeTransaction( - userWalletId = userWalletId, - fromCryptoCurrency = currencyToSend.currency, - toCryptoCurrency = currencyToGet.currency, - fromAccount = fromAccount, - toAccount = toAccount, + fromUserWalletId = fromSwapCurrencyStatus.userWalletId, + toUserWalletId = toSwapCurrencyStatus.userWalletId, + fromCryptoCurrency = fromSwapCurrencyStatus.currency, + toCryptoCurrency = toSwapCurrencyStatus.currency, + fromAccount = fromSwapCurrencyStatus.account, + toAccount = toSwapCurrencyStatus.account, transaction = SavedSwapTransactionModel( txId = swapDataModel.transaction.txId, provider = swapProvider, @@ -1140,10 +948,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongParameterList") override suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, @@ -1161,16 +966,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( return if (selectedFeeToken != null) { estimateFeeForTokenUseCase( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, feeTokenCurrencyStatus = selectedFeeToken, - sendingTokenCurrencyStatus = fromToken, + sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, amount = amountDecimal, ) } else { estimateFeeForGaslessTxUseCase( amount = amountDecimal, - userWallet = userWallet, - sendingTokenCurrencyStatus = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, ) } } @@ -1178,10 +983,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, @@ -1190,39 +993,36 @@ internal class SwapInteractorImpl @AssistedInject constructor( ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE, -> { - val fromNetworkAddress = fromToken.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toToken.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() val amountBigDecimal = toBigDecimalOrNull(amount) if (amountBigDecimal == null || amountBigDecimal.signum() == 0) { raise(GetFeeError.UnknownError) } - val swapAmount = SwapAmount(amountBigDecimal, fromToken.currency.decimals) + val swapAmount = SwapAmount(amountBigDecimal, fromSwapCurrencyStatus.currency.decimals) repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, - toContractAddress = toToken.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.backendId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = swapAmount.toStringWithRightOffset(), fromDecimals = swapAmount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, toAddress = dexToAddress, - refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, expressOperationType = ExpressOperationType.SWAP, ).map { swapData -> - val networkId = fromToken.currency.network.backendId val transaction = swapData.transaction as ExpressTransactionModel.DEX - loadFeeForDex( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken, ).getOrElse { raise(GetFeeError.UnknownError) } }.mapLeft { GetFeeError.UnknownError @@ -1236,8 +1036,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( estimateFeeUseCase.invoke( amount = amountDecimal, - userWallet = userWallet, - cryptoCurrencyStatus = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).map { it.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) } @@ -1245,10 +1045,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun storeLastCryptoCurrencyId(cryptoCurrency: CryptoCurrency) { + private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( - userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrency.id, + userWalletId = swapCurrencyStatus.userWalletId, + cryptoCurrencyId = swapCurrencyStatus.currency.id, ) } @@ -1256,32 +1056,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals) } - override suspend fun getInitialCurrencyToSwap( - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? { - val group = state.getGroupWithReverse(isReverseFromTo) - return initialToCurrencyResolver.tryGetFromCache( - userWallet = userWallet, - initialCryptoCurrency = initialCryptoCurrency, - state = state, - isReverseFromTo = isReverseFromTo, - ) - ?: initialToCurrencyResolver.tryGetWithMaxAmount(state = state, isReverseFromTo = isReverseFromTo) - ?: group.accountCurrencyList.firstNotNullOfOrNull { accountSwapAvailability -> - accountSwapAvailability.currencyList.firstOrNull { accountSwapCurrency -> - accountSwapCurrency.isAvailable - } - } - } - - override suspend fun getNativeToken(network: Network): CryptoCurrency { + override suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency { + val network = swapCurrencyStatus.currency.network return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + params = MultiWalletCryptoCurrenciesProducer.Params(swapCurrencyStatus.userWalletId), ) ?.filterIsInstance() - ?.firstOrNull { it.network.id == network.id && it.network.derivationPath == network.derivationPath } + ?.firstOrNull { nativeCoin -> + nativeCoin.network.id == network.id && + nativeCoin.network.derivationPath == network.derivationPath + } ?: currenciesRepository.createCoinCurrency(network) } @@ -1304,32 +1088,28 @@ internal class SwapInteractorImpl @AssistedInject constructor( */ @Suppress("LongParameterList") private suspend fun loadCexQuoteData( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, txFeeSealedState: TxFeeSealedState, ): SwapState { - val fromToken = fromTokenStatus.currency - val toToken = toTokenStatus.currency + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency return coroutineScope { val txFeeSealedStateUpdated = updateTxFeeStateIfNeededForCEX( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, txFeeSealedState = txFeeSealedState, amount = amount, - fromTokenStatus = fromTokenStatus, ) val includeFeeInAmount = getIncludeFeeInAmount( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromTokenStatus, txFeeSealedState = txFeeSealedStateUpdated, ) @@ -1340,11 +1120,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val quotes = repository.findBestQuote( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, fromContractAddress = fromToken.getContractAddress(), - fromNetwork = fromToken.network.backendId, + fromNetwork = fromToken.network.rawId, toContractAddress = toToken.getContractAddress(), - toNetwork = toToken.network.backendId, + toNetwork = toToken.network.rawId, fromAmount = amountToRequest.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.decimals, @@ -1356,11 +1136,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = quotes, amount = amount, - fromToken = fromTokenStatus, - fromAccount = fromAccount, - toToken = toTokenStatus, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, @@ -1370,9 +1147,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun updateTxFeeStateIfNeededForCEX( + fromSwapCurrencyStatus: SwapCurrencyStatus, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, - fromTokenStatus: CryptoCurrencyStatus, ): TxFeeSealedState { return when (txFeeSealedState) { is TxFeeSealedState.Component -> txFeeSealedState @@ -1380,10 +1157,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (txFeeSealedState.txFeeState is TxFeeState.Empty) { val txFeeResult = estimateFeeUseCase( amount = amount.value, - userWallet = userWallet, - cryptoCurrencyStatus = fromTokenStatus, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ) - val txFee = getFeeForCex(txFeeResult, fromTokenStatus) + val txFee = getFeeForCex(txFeeResult, fromSwapCurrencyStatus) TxFeeSealedState.Legacy( txFeeState = txFee, @@ -1401,11 +1178,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, quoteDataModel: Either, amount: SwapAmount, - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, txFeeSealedState: TxFeeSealedState, @@ -1414,10 +1188,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( return quoteDataModel.fold( ifRight = { quoteModel -> val swapState = updateBalances( - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, @@ -1425,16 +1197,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, ).copy( currencyCheck = manageWarnings( - fromTokenStatus = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( - fromToken = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, - userWalletId = userWalletId, ), minAdaValue = when (txFeeSealedState) { is TxFeeSealedState.Component -> { @@ -1455,18 +1226,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { val state = updatePermissionState( - networkId = networkId, - fromTokenStatus = fromToken, - fromAccount = fromAccount, - swapAmount = amount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, - spenderAddress = quoteModel.allowanceContract, + swapAmount = amount, + quoteModel = quoteModel, ) if (state !is SwapState.QuotesLoadedState) return state state.copy( preparedSwapConfigState = state.preparedSwapConfigState.copy( - isAllowedToSpend = isAllowedToSpend, isBalanceEnough = isBalanceWithoutFeeEnough, ), ) @@ -1484,18 +1252,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, fee = fee, spendAmount = amount, - networkId = networkId, - fromTokenStatus = fromToken, ) swapState.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = PreparedSwapConfigState( feeState = feeState, - isAllowedToSpend = isAllowedToSpend, isBalanceEnough = isBalanceWithoutFeeEnough, - hasOutgoingTransaction = hasOutgoingTransaction(fromToken), + hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), includeFeeInAmount = includeFeeInAmount, ), ) @@ -1504,8 +1270,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( }, ifLeft = { error -> createSwapErrorWith( - fromToken = fromToken, - fromAccount = fromAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, includeFeeInAmount = includeFeeInAmount, expressDataError = error, @@ -1515,46 +1280,41 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun createSwapErrorWith( - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, ): SwapState.SwapError { - val rates = getQuotes(fromToken.currency.id) + val rates = getQuotes(fromSwapCurrencyStatus.currency.id) val fromTokenSwapInfo = TokenSwapInfo( + swapCurrencyStatus = fromSwapCurrencyStatus, tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) - ?: BigDecimal.ZERO, - cryptoCurrencyStatus = fromToken, - account = fromAccount, + amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) } @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "CastNullableToNonNullableType") private suspend fun getIncludeFeeInAmount( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromToken: CryptoCurrencyStatus, txFeeSealedState: TxFeeSealedState, ): IncludeFeeInAmount { return when (txFeeSealedState) { is TxFeeSealedState.Component -> { - if (fromToken.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { + if (fromSwapCurrencyStatus.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO if (txFeeSealedState.txFee.selectedToken.currency is CryptoCurrency.Coin) { getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = fee, ) } else { // we have a token selected for fee payment the same as sending token - val reducedBalance = fromToken.value.amount as BigDecimal - reduceBalanceBy + val reducedBalance = fromSwapCurrencyStatus.status.value.amount as BigDecimal - reduceBalanceBy when { amount.value > reducedBalance -> IncludeFeeInAmount.BalanceNotEnough amount.value + fee <= reducedBalance -> IncludeFeeInAmount.Excluded @@ -1563,7 +1323,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( IncludeFeeInAmount.Included( amountSubtractFee = SwapAmount( value = reducedBalance - fee, - decimals = fromToken.currency.decimals, + decimals = fromSwapCurrencyStatus.currency.decimals, ), ) } else { @@ -1575,10 +1335,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( } else { val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = fee, ) } @@ -1592,10 +1351,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee } getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = feeValue, ) } @@ -1603,17 +1361,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getIncludeFeeInAmountForNative( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromToken: CryptoCurrency, feeValue: BigDecimal, ): IncludeFeeInAmount { - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken, - ) - - return when (feePaidCurrency) { + return when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { is FeePaidCurrency.Token -> { if (feePaidCurrency.balance > feeValue) { IncludeFeeInAmount.Excluded @@ -1622,31 +1375,30 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } else -> getIncludeFeeAmountForCoinFee( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, feeValue = feeValue, - fromToken = fromToken, ) } } private suspend fun getIncludeFeeAmountForCoinFee( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, feeValue: BigDecimal, - fromToken: CryptoCurrency, ): IncludeFeeInAmount { + val networkId = fromSwapCurrencyStatus.currency.network.rawId val tokenForFeeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, + userWalletId = fromSwapCurrencyStatus.userWalletId, networkId = networkId, - derivationPath = fromToken.network.derivationPath.value, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) val reducedBalance = tokenForFeeBalance - reduceBalanceBy val amountWithFee = amount.value + feeValue return when { - fromToken is CryptoCurrency.Token -> { + fromSwapCurrencyStatus.currency is CryptoCurrency.Token -> { if (feeValue > reducedBalance || reducedBalance.signum() == 0) { IncludeFeeInAmount.BalanceNotEnough } else { @@ -1661,8 +1413,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } else -> { if (feeValue < amount.value) { - val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals() - ?: error("Blockchain not found") + val nativeCoinDecimals = + Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") IncludeFeeInAmount.Included( amountSubtractFee = SwapAmount( reducedBalance - feeValue, @@ -1676,14 +1428,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun getFormattedFiatFees(fromToken: CryptoCurrency, vararg fees: BigDecimal): List { + private suspend fun getFormattedFiatFees( + fromSwapCurrencyStatus: SwapCurrencyStatus, + vararg fees: BigDecimal, + ): List { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken, - ) - val feeCurrencyId: CryptoCurrency.ID = when (feePaidCurrency) { + val feeCurrencyId: CryptoCurrency.ID = when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { is FeePaidCurrency.Token -> feePaidCurrency.tokenId - else -> getNativeToken(network = fromToken.network).id + else -> getNativeToken(fromSwapCurrencyStatus).id } val rates = getQuotes(feeCurrencyId) return rates[feeCurrencyId]?.let { rate -> @@ -1704,55 +1456,49 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongParameterList", "LongMethod") private suspend fun loadDexSwapData( provider: SwapProvider, - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, ): SwapState { - val fromNetworkAddress = fromToken.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toToken.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() + val networkId = fromSwapCurrencyStatus.currency.network.rawId return repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, - toContractAddress = toToken.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.backendId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, toAddress = dexToAddress, - refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, expressOperationType = expressOperationType, ).fold( ifRight = { swapData -> val transaction = swapData.transaction as ExpressTransactionModel.DEX - val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals() - ?: error("Blockchain not found") - val otherNativeFee = transaction.otherNativeFeeWei - ?.movePointLeft(nativeCoinDecimals) - ?: BigDecimal.ZERO + val nativeCoinDecimals = + Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") + val otherNativeFee = transaction.otherNativeFeeWei?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO val txFeeState = loadFeeForDex( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken, ).getOrElse { error -> return@fold produceDexSwapDataError( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, error = error, - fromToken = fromToken, - fromAccount = fromAccount, amount = amount, ) - }.toTxFeeState(fromToken.currency, otherNativeFee) + }.toTxFeeState(fromSwapCurrencyStatus, otherNativeFee) val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex val feeByPriority = when (txFeeSealedState) { @@ -1764,25 +1510,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO) - val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeToCheckFunds) + val isBalanceIncludeFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, feeToCheckFunds) val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, fee = feeToCheckFunds, spendAmount = amount, - networkId = networkId, - fromTokenStatus = fromToken, ) val preparedSwapConfigState = PreparedSwapConfigState( - isAllowedToSpend = true, isBalanceEnough = isBalanceIncludeFeeEnough, feeState = feeState, - hasOutgoingTransaction = hasOutgoingTransaction(fromToken), + hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), includeFeeInAmount = includeFeeInAmount, ) val swapState = updateBalances( - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, @@ -1792,25 +1534,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapState.copy( permissionState = PermissionDataState.Empty, currencyCheck = manageWarnings( - fromTokenStatus = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( - fromToken = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, - userWalletId = userWalletId, ), preparedSwapConfigState = preparedSwapConfigState, ) }, ifLeft = { error -> produceDexSwapDataError( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, error = error, - fromToken = fromToken, - fromAccount = fromAccount, amount = amount, ) }, @@ -1818,48 +1558,44 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun loadFeeForDex( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, transaction: ExpressTransactionModel.DEX, - fromToken: CryptoCurrencyStatus, ): Either = either { - if (isSolana(networkId)) { + if (isSolana(fromSwapCurrencyStatus.currency.network.rawId)) { val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) val formattedHash = getFormattedHash(transactionBytes) - if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && userWallet is UserWallet.Cold) { + if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && + fromSwapCurrencyStatus.userWallet is UserWallet.Cold + ) { raise(ExpressDataError.TooLargeSolanaTransactionError) } getFeeDataForSolanaDexSwap( - network = fromToken.currency.network, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transactionBytes = transactionBytes, ) } else { getFeeDataForDexSwap( - network = fromToken.currency.network, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken.currency, ).map { fee -> - (fee as TransactionFeeResult.Loaded).fee - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + (fee as TransactionFeeResult.Loaded).fee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) }.bind() } } private suspend fun produceDexSwapDataError( + fromSwapCurrencyStatus: SwapCurrencyStatus, error: ExpressDataError, - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, amount: SwapAmount, ): SwapState.SwapError { - val rates = getQuotes(fromToken.currency.id) + val rates = getQuotes(fromSwapCurrencyStatus.currency.id) val fromTokenSwapInfo = TokenSwapInfo( + swapCurrencyStatus = fromSwapCurrencyStatus, tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) - ?: BigDecimal.ZERO, - cryptoCurrencyStatus = fromToken, - account = fromAccount, + amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) return SwapState.SwapError( fromTokenSwapInfo, @@ -1870,15 +1606,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("CyclomaticComplexMethod") private suspend fun getFeeDataForDexSwap( - network: Network, + fromSwapCurrencyStatus: SwapCurrencyStatus, transaction: ExpressTransactionModel.DEX, - fromToken: CryptoCurrency, selectedToken: CryptoCurrencyStatus? = null, ): Either = either { val nativeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = network.backendId, - derivationPath = fromToken.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromSwapCurrencyStatus.currency.network.rawId, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) // if native balance is zero - we can't calculate fee @@ -1888,7 +1623,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( try { val txAmountValue = transaction.txValue ?: error("unable to get txValue") - val amountToSend = createNativeAmountForDex(txAmountValue, fromToken.network) + val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network) // transaction.txValue is always native coin if (nativeBalance < amountToSend.value) { @@ -1897,7 +1632,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val extras = createTransactionExtrasUseCase( data = transaction.txData, - network = network, + network = fromSwapCurrencyStatus.currency.network, ).getOrNull() ?: error("unable to create extras") val transactionData = TransactionData.Uncompiled( @@ -1911,68 +1646,65 @@ internal class SwapInteractorImpl @AssistedInject constructor( getFeeForTokenUseCase( transactionData = transactionData, token = selectedToken.currency, - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) } ?: error("unable to calculate fee for token") } else { getFeeUseCase( transactionData = transactionData, - network = network, - userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee") } } catch (_: IllegalStateException) { getEthSpecificFeeUseCase( - userWallet = userWallet, - cryptoCurrency = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrency = fromSwapCurrencyStatus.currency, gasLimit = transaction.gas, ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("can't get fee for getEthSpecificFeeUseCase") } } - private suspend fun getFeeDataForSolanaDexSwap(network: Network, transactionBytes: ByteArray): TransactionFee { + private suspend fun getFeeDataForSolanaDexSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + transactionBytes: ByteArray, + ): TransactionFee { val transactionData = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(transactionBytes), ) return getFeeUseCase( transactionData = transactionData, - network = network, - userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull() ?: error("unable to calculate fee") } @Suppress("LongParameterList", "MaxChainedCallsOnSameLine") private suspend fun updateBalances( provider: SwapProvider, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, txFeeSealedState: TxFeeSealedState, ): SwapState.QuotesLoadedState { - val fromToken = fromTokenStatus.currency - val toToken = toTokenStatus.currency - val nativeToken = getNativeToken(fromToken.network) + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency + val nativeToken = getNativeToken(fromSwapCurrencyStatus) val rates = getQuotes(fromToken.id, toToken.id, nativeToken.id) return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, - account = fromAccount, - cryptoCurrencyStatus = fromTokenStatus, - amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) - ?: BigDecimal.ZERO, + swapCurrencyStatus = fromSwapCurrencyStatus, + amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) ?: BigDecimal.ZERO, ), toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, - cryptoCurrencyStatus = toTokenStatus, - account = toAccount, - amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) - ?: BigDecimal.ZERO, + swapCurrencyStatus = toSwapCurrencyStatus, + amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) ?: BigDecimal.ZERO, ), priceImpact = calculatePriceImpact( fromTokenAmount = fromTokenAmount.value, @@ -1986,9 +1718,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TxFeeSealedState.Component -> { when (txFeeSealedState.txFee.transactionFeeResult) { is TransactionFeeResult.Loaded -> - txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState(fromToken, null) + txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + otherNativeFee = null, + ) is TransactionFeeResult.LoadedExtended -> - txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState(fromToken, null) + txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + otherNativeFee = null, + ) } } is TxFeeSealedState.Legacy -> txFeeSealedState.txFeeState @@ -1999,36 +1737,31 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun getFeeForCex( txFeeResult: Either?, - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, ): TxFeeState { return txFeeResult?.fold( ifLeft = { TxFeeState.Empty }, ifRight = { txFee -> - txFee - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) - .toTxFeeState(fromToken.currency, null) + txFee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND).toTxFeeState(fromSwapCurrencyStatus, null) }, ) ?: TxFeeState.Empty } - @Suppress("LongParameterList", "LongMethod", "CanBeNonNullable") private suspend fun updatePermissionState( - networkId: String, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, - spenderAddress: String?, + quoteModel: QuoteModel, isAllowedToSpend: Boolean, ): SwapState { - val fromToken = fromTokenStatus.currency + val fromToken = fromSwapCurrencyStatus.currency if (isAllowedToSpend) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, ) } // if token balance ZERO not show permission state to avoid user to spend money for fee - val isTokenZeroBalance = getTokenBalance(fromTokenStatus).value.signum() == 0 + val isTokenZeroBalance = getTokenBalance(fromSwapCurrencyStatus.status).value.signum() == 0 if (isTokenZeroBalance) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, @@ -2039,84 +1772,25 @@ internal class SwapInteractorImpl @AssistedInject constructor( permissionState = PermissionDataState.PermissionLoading, ) } - // setting up amount for approve with given amount for swap [SwapApproveType.Limited] - val fromAddress = requireNotNull( - fromTokenStatus.value.networkAddress?.defaultAddress?.value, - ) { "networkAddress cant be null" } val allowanceInfo = getAllowanceInfoUseCase( - userWalletId = userWalletId, + userWalletId = fromSwapCurrencyStatus.userWalletId, cryptoCurrency = fromToken, - spenderAddress = requireNotNull(spenderAddress) { "spenderAddress cant be null" }, + spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }, requiredAmount = swapAmount.value, ).getOrNull() - val amount = if (allowanceInfo is AllowanceInfo.ResetNeeded) { - BigDecimal.ZERO - } else { - swapAmount.value - } - - val approveTransaction = createApprovalTransactionUseCase( - cryptoCurrencyStatus = fromTokenStatus, - userWalletId = userWalletId, - amount = amount, - contractAddress = fromToken.getContractAddress(), - spenderAddress = spenderAddress, - ).getOrElse { error -> - TangemLogger.e("Failed to create approveTransaction", error) - return createSwapErrorWith( - fromToken = fromTokenStatus, - fromAccount = fromAccount, - amount = swapAmount, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - expressDataError = ExpressDataError.UnknownError, - ) - } - - val feeData = getFeeUseCase( - transactionData = approveTransaction, - network = fromToken.network, - userWallet = userWallet, - ).getOrNull() ?: error("unable to calculate fee") - - val feeState = feeData - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) - .toTxFeeState(fromToken, null) - - val fee = when (feeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value - is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value - } - val swapFeeState = getFeeState( - fee = fee, - spendAmount = SwapAmount.zeroSwapAmount(), - networkId = networkId, - fromTokenStatus = fromTokenStatus, - ) return quotesLoadedState.copy( - permissionState = PermissionDataState.PermissionReadyForRequest( - currency = fromToken.symbol, - amount = INFINITY_SYMBOL, - walletAddress = getWalletAddress(fromToken.network), - spenderAddress = getTokenAddress(fromToken), + permissionState = PermissionDataState.PermissionRequired( isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded, - requestApproveData = RequestApproveStateData( - fee = feeState, - fromTokenAmount = swapAmount, - spenderAddress = spenderAddress, - ), - ), - preparedSwapConfigState = quotesLoadedState.preparedSwapConfigState.copy( - feeState = swapFeeState, + spenderAddress = quoteModel.allowanceContract, ), ) } @Suppress("LongMethod") private suspend fun TransactionFee.toTxFeeState( - fromToken: CryptoCurrency, + fromSwapCurrencyStatus: SwapCurrencyStatus, otherNativeFee: BigDecimal?, ): TxFeeState { val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO @@ -2124,8 +1798,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TransactionFee.Choosable -> { val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO val feePriority = this.priority.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] - val priorityFiatValue = getFormattedFiatFees(fromToken, feePriority)[0] + val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] + val priorityFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feePriority)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, @@ -2139,8 +1813,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( // region otherNativeFee val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue val priorityFeeWithOtherNative = feePriority + otherNativeFeeValue - val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0] - val priorityFiatValueWithNative = getFormattedFiatFees(fromToken, priorityFeeWithOtherNative)[0] + val normalFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] + val priorityFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, priorityFeeWithOtherNative)[0] val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeWithOtherNative, @@ -2178,14 +1854,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( } is TransactionFee.Single -> { val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] + val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, decimals = this.normal.amount.decimals, ) // region otherNativeFee val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue - val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0] + val normalFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeWithOtherNative, @@ -2210,7 +1887,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private fun createNativeAmountForDex(txValueAmount: String, network: Network): Amount { - val nativeDecimals = Blockchain.fromNetworkId(network.backendId)?.decimals() + val nativeDecimals = Blockchain.fromNetworkId(network.rawId)?.decimals() ?: error("Blockchain not found") val decimalValue = txValueAmount.toBigDecimalOrNull()?.movePointLeft(nativeDecimals) ?: error("txValue parse error") @@ -2273,9 +1950,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val gasLimit = this.gasLimit val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) - val increasedGasLimit = gasLimit - .multiply(percentage.toBigInteger()) - .divide(hundredPercent) + val increasedGasLimit = gasLimit.multiply(percentage.toBigInteger()).divide(hundredPercent) val increasedAmount = this.amount.copy( value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), ) @@ -2312,18 +1987,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun isBalanceEnough( - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, fee: BigDecimal?, ): Boolean { - val tokenBalance = getTokenBalance(fromToken).value - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken.currency, - ) + val tokenBalance = getTokenBalance(fromSwapCurrencyStatus.status).value + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) return when (feePaidCurrency) { is FeePaidCurrency.Token -> tokenBalance >= amount.value else -> { - if (fromToken.currency is CryptoCurrency.Token) { + if (fromSwapCurrencyStatus.currency is CryptoCurrency.Token) { tokenBalance >= amount.value } else { tokenBalance >= amount.value.plus(fee ?: BigDecimal.ZERO) @@ -2332,18 +2005,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun getFeePaidCurrency(currency: CryptoCurrency): FeePaidCurrency { + private suspend fun getFeePaidCurrency(swapCurrencyStatus: SwapCurrencyStatus): FeePaidCurrency { return currenciesRepository.getFeePaidCurrency( - userWalletId = userWalletId, - network = currency.network, + userWalletId = swapCurrencyStatus.userWalletId, + network = swapCurrencyStatus.currency.network, ) } - private suspend fun getWalletAddress(network: Network): String { - return walletManagersFacade.getDefaultAddress(userWalletId, network) - ?: error("Address not found for network: ${network.id}") - } - private fun getTokenAddress(currency: CryptoCurrency): String { return when (currency) { is CryptoCurrency.Coin -> { @@ -2361,25 +2029,24 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun getFeeState( + fromSwapCurrencyStatus: SwapCurrencyStatus, fee: BigDecimal?, spendAmount: SwapAmount, - networkId: String, - fromTokenStatus: CryptoCurrencyStatus, ): SwapFeeState { if (fee == null) { return SwapFeeState.NotEnough() } - + val fromCurrency = fromSwapCurrencyStatus.currency val percentsToFeeIncrease = BigDecimal.ONE - return when (val feePaidCurrency = getFeePaidCurrency(fromTokenStatus.currency)) { + return when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { FeePaidCurrency.Coin -> { val nativeTokenBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = networkId, - derivationPath = fromTokenStatus.currency.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromCurrency.network.rawId, + derivationPath = fromCurrency.network.derivationPath.value, ) - val balanceToCheck = when (fromTokenStatus.currency) { + val balanceToCheck = when (fromCurrency) { is CryptoCurrency.Token -> nativeTokenBalance is CryptoCurrency.Coin -> { // need to check balance minus amount only if amount to swap in native token @@ -2389,23 +2056,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (balanceToCheck > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val nativeToken = getNativeToken(fromTokenStatus.currency.network) + val nativeToken = getNativeToken(fromSwapCurrencyStatus) SwapFeeState.NotEnough( - feeCurrency = nativeToken, currencyName = nativeToken.network.name, currencySymbol = nativeToken.symbol, ) } } FeePaidCurrency.SameCurrency -> { - val balance = fromTokenStatus.value.amount ?: return SwapFeeState.NotEnough() + val balance = fromSwapCurrencyStatus.status.value.amount ?: return SwapFeeState.NotEnough() if (balance.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { SwapFeeState.NotEnough( - feeCurrency = fromTokenStatus.currency, - currencyName = fromTokenStatus.currency.name, - currencySymbol = fromTokenStatus.currency.symbol, + currencyName = fromCurrency.name, + currencySymbol = fromCurrency.symbol, ) } } @@ -2413,20 +2078,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - - val token = tokens - .filterIsInstance() - .find { cryptoToken -> - cryptoToken.contractAddress.equals(feePaidCurrency.contractAddress, ignoreCase = true) && - cryptoToken.network.derivationPath == fromTokenStatus.currency.network.derivationPath - } - SwapFeeState.NotEnough( - feeCurrency = token, currencyName = feePaidCurrency.name, currencySymbol = feePaidCurrency.symbol, ) @@ -2435,8 +2087,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( is FeePaidCurrency.FeeResource -> { val isFeeResourceEnough = currencyChecksRepository.checkIfFeeResourceEnough( amount = spendAmount.value, - userWalletId = userWalletId, - network = fromTokenStatus.currency.network, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromCurrency.network, ) if (isFeeResourceEnough) { @@ -2498,14 +2150,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( val set = ids.mapNotNullTo(destination = hashSetOf(), transform = CryptoCurrency.ID::rawCurrencyId) .getQuotesOrEmpty() - return ids - .mapNotNull { id -> - val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data } - ?: return@mapNotNull null + return ids.mapNotNull { id -> + val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data } + ?: return@mapNotNull null - id to found.value as QuoteStatus.Data - } - .toMap() + id to found.value as QuoteStatus.Data + }.toMap() } private suspend fun Set.getQuotesOrEmpty(): Set { @@ -2544,7 +2194,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return try { SolanaTransactionHelper.removeSignaturesPlaceholders(hash) } catch (e: Exception) { - TangemLogger.e("Failed to format the hash: ${e.message.orEmpty()}") + TangemLogger.e("Failed to format the hash: ${e.message.orEmpty()}", e) hash } } @@ -2558,6 +2208,40 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } + // region temporary. will be removed + private fun CryptoCurrency.getContractAddress(): String { + return when (this) { + is CryptoCurrency.Token -> this.contractAddress + is CryptoCurrency.Coin -> "0" + } + } + + private fun ExpressProvider.toSwapProvider(): SwapProvider { + return SwapProvider( + providerId = providerId, + rateTypes = rateTypes.map { rateType -> + when (rateType) { + ExpressRateType.Float -> RateType.FLOAT + ExpressRateType.Fixed -> RateType.FIXED + } + }, + name = name, + type = when (type) { + ExpressProviderType.DEX -> ExchangeProviderType.DEX + ExpressProviderType.CEX -> ExchangeProviderType.CEX + ExpressProviderType.DEX_BRIDGE -> ExchangeProviderType.DEX_BRIDGE + ExpressProviderType.ONRAMP -> error("Invalid provider type") + }, + imageLarge = imageLarge, + termsOfUse = termsOfUse, + privacyPolicy = privacyPolicy, + isRecommended = isRecommended, + slippage = slippage, + isExtraIdSupported = isExtraIdSupported, + ) + } + // endregion + companion object { private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12% private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% @@ -2566,12 +2250,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD private val PRICE_IMPACT_LOW_THRESHOLD = 0.1.toBigDecimal() // 10% private val PRICE_IMPACT_HIGH_THRESHOLD = 0.5.toBigDecimal() // 50% - private const val INFINITY_SYMBOL = "∞" - } - - @AssistedFactory - interface Factory : SwapInteractor.Factory { - override fun create(selectedWalletId: UserWalletId): SwapInteractorImpl } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index 0720aa7abf..03b73c515b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -14,7 +14,8 @@ interface SwapTransactionRepository { @Suppress("LongParameterList") suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index c197aac5a9..9a38c5448b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.* @@ -23,7 +24,11 @@ interface SwapRepository { isIgnoreExpress: Boolean = false, ): PairsWithProviders - suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): Either + suspend fun getExchangeStatus( + userWallet: UserWallet?, + userWalletId: UserWalletId, + txId: String, + ): Either @Suppress("LongParameterList") suspend fun findBestQuote( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 26b53125e0..c753a3381d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -1,6 +1,10 @@ package com.tangem.feature.swap.domain.di -import com.tangem.feature.swap.domain.* +import com.tangem.feature.swap.domain.AllowPermissionsHandler +import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl +import com.tangem.feature.swap.domain.SwapInteractor +import com.tangem.feature.swap.domain.SwapInteractorImpl +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,20 +20,13 @@ internal class SwapDomainModule { fun provideAllowPermissionsHandler(): AllowPermissionsHandler { return AllowPermissionsHandlerImpl() } +} - @Provides - @Singleton - fun provideSwapInteractorFactory(factory: SwapInteractorImpl.Factory): SwapInteractor.Factory { - return factory - } +@Module +@InstallIn(SingletonComponent::class) +internal interface SwapDomainBindModule { - @Provides + @Binds @Singleton - fun provideInitialToCurrencyResolver( - swapTransactionRepository: SwapTransactionRepository, - ): InitialToCurrencyResolver { - return DefaultInitialToCurrencyResolver( - swapTransactionRepository = swapTransactionRepository, - ) - } + fun provideSwapInteractor(swapInteractor: SwapInteractorImpl): SwapInteractor } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt deleted file mode 100644 index 383d8fe28f..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -data class NetworkInfo( - val name: String, - val blockchainId: String, -) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt deleted file mode 100644 index e07d257628..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData -import com.tangem.feature.swap.domain.models.ui.TxFee - -/** - * Permission options - * - * @param approveData tx data to give approve, it loaded from 1inch in findBestQuote if needed - * @param forTokenContractAddress token contract address for which needs permission - * @param fromTokenStatus which token will be swapping - * @param approveType unlimited or tx amount approve - * @param txFee fee for tx - */ -data class PermissionOptions( - val approveData: RequestApproveStateData, - val forTokenContractAddress: String, - val fromTokenStatus: CryptoCurrencyStatus, - val spenderAddress: String, - val approveType: SwapApproveType, - val txFee: TxFee, -) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt index ec5293ae96..8f1eab0d73 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -5,12 +5,10 @@ import com.tangem.feature.swap.domain.models.SwapAmount /** * Prepared swap config state that contains flags to determine * - * @property isAllowedToSpend shows is token allowed to spend * @property isBalanceEnough shows is balance of token enough */ // todo Refactor this state data class PreparedSwapConfigState( - val isAllowedToSpend: Boolean, val isBalanceEnough: Boolean, val feeState: SwapFeeState, val hasOutgoingTransaction: Boolean, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt index 31236c6313..1480a38558 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt @@ -8,7 +8,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal data class SavedSwapTransactionListModel( - val userWalletId: String, + val fromUserWalletId: String, + val toUserWalletId: String, val fromCryptoCurrencyId: String, val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, @@ -25,7 +26,9 @@ data class SavedSwapTransactionListModel( @JsonClass(generateAdapter = true) data class SavedSwapTransactionListModelInner( @Json(name = "userWalletId") - val userWalletId: String, + val fromUserWalletId: String, + @Json(name = "toUserWalletId") + val toUserWalletId: String = fromUserWalletId, @Json(name = "fromCryptoCurrencyId") val fromCryptoCurrencyId: String, @Json(name = "toCryptoCurrencyId") diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt deleted file mode 100644 index 21ded70713..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -enum class SwapApproveType { - LIMITED, UNLIMITED -} - -fun SwapApproveType.getNameForAnalytics(): String { - return when (this) { - SwapApproveType.LIMITED -> "Transaction" - SwapApproveType.UNLIMITED -> "Unlimited" - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt index e4a002e630..b4ceb30a64 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt @@ -1,11 +1,8 @@ package com.tangem.feature.swap.domain.models.domain -import com.tangem.domain.models.currency.CryptoCurrency - sealed class SwapFeeState { data object Enough : SwapFeeState() data class NotEnough( - val feeCurrency: CryptoCurrency? = null, val currencyName: String? = null, val currencySymbol: String? = null, ) : SwapFeeState() diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index f65dd60a98..9c1d643ad4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -59,11 +59,21 @@ data class SwapProvider( @JsonClass(generateAdapter = false) enum class ExchangeProviderType(val providerName: String) { - @Json(name = "DEX") DEX("DEX"), + @Json(name = "DEX") + DEX("DEX"), - @Json(name = "CEX") CEX("CEX"), + @Json(name = "CEX") + CEX("CEX"), - @Json(name = "DEX_BRIDGE") DEX_BRIDGE("DEX/Bridge"), + @Json(name = "DEX_BRIDGE") + DEX_BRIDGE("DEX/Bridge"), + ; + + companion object { + fun getSwapProviderTypes(): List { + return listOf(CEX, DEX, DEX_BRIDGE) + } + } } /** @@ -73,7 +83,9 @@ enum class ExchangeProviderType(val providerName: String) { */ @JsonClass(generateAdapter = false) enum class RateType { - @Json(name = "FLOAT") FLOAT, + @Json(name = "FLOAT") + FLOAT, - @Json(name = "FIXED") FIXED, + @Json(name = "FIXED") + FIXED, } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 89f79b2d72..562869ee5a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -3,8 +3,8 @@ package com.tangem.feature.swap.domain.models.ui import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.transaction.Fee import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError @@ -23,7 +23,6 @@ sealed interface SwapState { val toTokenInfo: TokenSwapInfo, val priceImpact: PriceImpact, val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState( - isAllowedToSpend = false, isBalanceEnough = false, feeState = SwapFeeState.NotEnough(), hasOutgoingTransaction = false, @@ -81,17 +80,11 @@ data class PriceImpact( sealed class PermissionDataState { - data class PermissionReadyForRequest( - val currency: String, - val amount: String, - val walletAddress: String, - val spenderAddress: String, - val requestApproveData: RequestApproveStateData, + data class PermissionRequired( val isResetApproval: Boolean, + val spenderAddress: String, ) : PermissionDataState() - object PermissionFailed : PermissionDataState() - object PermissionLoading : PermissionDataState() object Empty : PermissionDataState() @@ -100,8 +93,7 @@ sealed class PermissionDataState { data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, - val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account.CryptoPortfolio?, + val swapCurrencyStatus: SwapCurrencyStatus, ) data class RequestApproveStateData( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt index b1380f6a3a..8f5d9f4fbc 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt @@ -1,9 +1,8 @@ package com.tangem.feature.swap.domain.models.ui -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -36,10 +35,8 @@ sealed class SwapTransactionState { ) : SwapTransactionState() { data class StoreTransactionData( - val currencyToSend: CryptoCurrencyStatus, - val currencyToGet: CryptoCurrencyStatus, - val fromAccount: Account?, - val toAccount: Account?, + val fromSwapCurrencyStatus: SwapCurrencyStatus, + val toSwapCurrencyStatus: SwapCurrencyStatus, val amount: SwapAmount, val swapProvider: SwapProvider, val swapDataModel: SwapDataModel, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt index f0587337d6..d2da881490 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -45,13 +45,13 @@ data class CurrenciesGroup( ) data class AccountSwapAvailability( - val account: Account.CryptoPortfolio, + val account: Account, val currencyList: List, ) data class AccountSwapCurrency( val isAvailable: Boolean, - val account: Account.CryptoPortfolio, + val account: Account, val cryptoCurrencyStatus: CryptoCurrencyStatus, val providers: List, ) \ No newline at end of file diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index 6cd1167b8e..f2ab5ca458 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -11,7 +11,14 @@ android { namespace = "com.tangem.feature.swap.presentation" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + /** Core modules */ implementation(projects.core.analytics) implementation(projects.core.analytics.models) @@ -56,6 +63,8 @@ dependencies { implementation(projects.domain.account.status) implementation(projects.domain.visa) implementation(projects.domain.markets) + implementation(projects.domain.swap) + implementation(projects.domain.swap.models) /** Feature modules */ implementation(projects.features.swap.domain) @@ -96,6 +105,10 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.kotlin.immutable.collections) implementation(deps.decompose.ext.compose) + implementation(deps.firebase.perf) { + exclude(group = "com.google.firebase", module = "protolite-well-known-types") + exclude(group = "com.google.protobuf", module = "protobuf-javalite") + } /** Tangem libs */ implementation(tangemDeps.blockchain) @@ -103,4 +116,8 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 27f301ffb3..bced0cb1ac 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -1,43 +1,48 @@ package com.tangem.feature.swap -import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop import com.arkivanov.essenty.lifecycle.subscribe -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState -import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.R +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload -import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent +import com.tangem.domain.models.wallet.isHotWallet import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel -import com.tangem.feature.swap.router.SwapNavScreen +import com.tangem.feature.swap.models.SwapPermissionUM +import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSuccessScreen import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.SwapComponent -import com.tangem.utils.extensions.isZero +import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import java.math.BigDecimal @Suppress("UnusedPrivateMember") internal class DefaultSwapComponent @AssistedInject constructor( @@ -48,21 +53,24 @@ internal class DefaultSwapComponent @AssistedInject constructor( private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, ) : SwapComponent, AppComponentContext by appComponentContext { - private val model: SwapModel = getOrCreateModel(params) + private val stackNavigation = StackNavigation() + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) - // todo swap create InnerRouter - private val chooseTokenComponent by lazy { - chooseTokenComponentFactory.create( - context = child("chooseTokenComponent"), - params = ChooseTokenComponent.Params( - bridge = model.chooseTokenBridge, - settings = ChooseTokenComponent.Settings.SwapTo, - analyticsPayload = setOf( - ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), - ), - ), - ) - } + private val model: SwapModel = getOrCreateModel(params, router = innerRouter) + + private val childStack = childStack( + key = STACK_KEY, + source = stackNavigation, + serializer = null, + initialConfiguration = SwapRoute.Main, + handleBackButton = true, + childFactory = { route, factoryContext -> + createChild(route, childByContext(factoryContext)) + }, + ) private val approvalSlot = childSlot( key = APPROVAL_SLOT_KEY, @@ -87,7 +95,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( } private val slotNavigation = SlotNavigation() - private val childSlot = childSlot( + private val feeSelectorSlot = childSlot( source = slotNavigation, serializer = null, key = FEE_SELECTOR_SLOT_KEY, @@ -118,6 +126,19 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } + private fun createChild(route: SwapRoute, factoryContext: AppComponentContext): ComposableContentComponent = + when (route) { + is SwapRoute.Main -> SwapMainChild() + is SwapRoute.Success -> SwapSuccessChild() + is SwapRoute.SelectToken -> { + val bridge = if (route.isFromDirection) model.chooseFromTokenBridge else model.chooseToTokenBridge + chooseTokenComponentFactory.create( + context = factoryContext, + params = ChooseTokenComponent.Params(bridge = bridge), + ) + } + } + data class FeeSelectorConfig( val sendingCurrencyStatus: CryptoCurrencyStatus, val feeCurrencyStatus: CryptoCurrencyStatus, @@ -127,18 +148,21 @@ internal class DefaultSwapComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle() - val fromCryptoCurrency by remember { derivedStateOf { dataState.fromCryptoCurrency } } + val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } } val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } val shouldHideBlock by remember { - derivedStateOf { toBigDecimalOrZero(dataState.amount).isZero() || model.uiState.isInsufficientFunds } + derivedStateOf { + dataState.amount?.parseBigDecimalOrNull().isNullOrZero() || model.uiState.isInsufficientFunds + } } LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) { if (shouldHideBlock) { TangemLogger.e( - "Dismissing fee selector: " + + messageString = "Dismissing fee selector: " + "shouldHideBlock = $shouldHideBlock, amount = ${dataState.amount}, " + "isInsufficientFunds = ${model.uiState.isInsufficientFunds}", + shouldSanitize = false, ) slotNavigation.dismiss() return@LaunchedEffect @@ -164,73 +188,91 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } - val feeSelectorChildStackState by childSlot.subscribeAsState() - val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance + val stackState by childStack.subscribeAsState() - Crossfade( + Children( + stack = stackState, modifier = Modifier.background(TangemTheme.colors.background.secondary), - targetState = model.currentScreen, - label = "", - ) { screen -> - when (screen) { - SwapNavScreen.Main -> SwapScreen( - stateHolder = model.uiState, - feeSelectorBlockComponent = feeSelectorBlockComponent, - ) - SwapNavScreen.Success -> { - val successState = model.uiState.successState - val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() - if (successState != null) { - SwapSuccessScreen( - state = successState, - feeSelectorUM = feeSelectorState, - onBack = model.uiState.onBackClicked, - ) - } else { - SwapScreen( - stateHolder = model.uiState, - feeSelectorBlockComponent = feeSelectorBlockComponent, - ) - } - } - SwapNavScreen.SelectToken -> chooseTokenComponent.Content(Modifier) - } + animation = stackAnimation { fade() }, + ) { child -> + child.instance.Content(Modifier) } val approvalSlotState by approvalSlot.subscribeAsState() approvalSlotState.child?.instance?.BottomSheet() } - fun getApprovalParams(): GiveApprovalComponent.Params? { - val permissionState = model.uiState.permissionState as? GiveTxPermissionState.ReadyForRequest - ?: return null - val fromCryptoCurrency = model.dataState.fromCryptoCurrency ?: return null + private inner class SwapMainChild : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + val feeSelectorChildState by feeSelectorSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildState.child?.instance + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) + } + } + + private inner class SwapSuccessChild : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + val successState = model.uiState.successState + val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() + if (successState != null) { + SwapSuccessScreen( + state = successState, + feeSelectorUM = feeSelectorState, + onBack = router::pop, + ) + } else { + val feeSelectorChildState by feeSelectorSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildState.child?.instance + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) + } + } + } + + private fun getApprovalParams(): GiveApprovalComponent.Params? { + val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null + val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null val providerName = model.dataState.selectedProvider?.name.orEmpty() + val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet return GiveApprovalComponent.Params( userWalletId = params.userWalletId, - cryptoCurrencyStatus = fromCryptoCurrency, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, feeCryptoCurrencyStatus = feeCryptoCurrency, amount = model.dataState.amount.orEmpty(), - spenderAddress = requireNotNull(model.dataState.approveDataModel).spenderAddress, + spenderAddress = permissionState.spenderAddress, amountFooter = if (permissionState.isResetApproval) { resourceReference(R.string.update_approval_permission_subtitle) } else { resourceReference( id = R.string.give_permission_swap_subtitle, - formatArgs = wrappedList(providerName, permissionState.currency), + formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol), ) }, feeFooter = resourceReference(R.string.swap_give_permission_fee_footer), isResetApproval = permissionState.isResetApproval, - isHoldToConfirm = model.isHoldToConfirmEnabled, + isHoldToConfirm = isHoldToConfirm, callback = model.approvalCallback, ) } - private fun toBigDecimalOrZero(bigDecimalString: String?): BigDecimal { - return bigDecimalString?.replace(",", ".")?.toBigDecimalOrNull() ?: BigDecimal.ZERO + private fun onChildBack() { + val isEmptyStack = childStack.value.backStack.isEmpty() + val isSuccess = model.uiState.successState != null + + val isPopSend = isEmptyStack || isSuccess + when { + isPopSend -> router.pop() + else -> stackNavigation.pop() + } } @AssistedFactory @@ -239,7 +281,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( } private companion object { - const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot" + const val STACK_KEY = "swapStack" const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot" const val APPROVAL_SLOT_KEY = "approvalSlot" } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index d02dc9c000..c202111fb6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -1,12 +1,5 @@ package com.tangem.feature.swap -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.swap.SwapFeatureToggles -internal class DefaultSwapFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : SwapFeatureToggles { - override val isMarketListFeatureEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.SWAP_MARKET_LIST_ENABLED) -} \ No newline at end of file +internal class DefaultSwapFeatureToggles : SwapFeatureToggles \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index e0fccb451d..12d6bb07b8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.analytics -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO @@ -38,11 +37,6 @@ sealed class SwapEvents( class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked") - class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents( - event = "Choose Token Screen Opened", - params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"), - ) - class ChooseTokenScreenResult( val isTokenChosen: Boolean, val token: String? = null, @@ -72,23 +66,6 @@ sealed class SwapEvents( ), ) - class ButtonPermissionApproveClicked( - val sendToken: String, - val receiveToken: String, - val approveType: ApproveType, - val provider: SwapProvider, - ) : SwapEvents( - event = "Button - Permission Approve", - params = mapOf( - "Send Token" to sendToken, - "Receive Token" to receiveToken, - "Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited", - "Provider" to provider.name, - ), - ) - - class ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel") - class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") @Suppress("NullableToStringCall", "LongParameterList") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapQuotePerformanceTracker.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapQuotePerformanceTracker.kt new file mode 100644 index 0000000000..baec303ec2 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapQuotePerformanceTracker.kt @@ -0,0 +1,36 @@ +package com.tangem.feature.swap.analytics + +import com.google.firebase.perf.FirebasePerformance +import com.google.firebase.perf.metrics.Trace + +internal class SwapQuotePerformanceTracker { + + private var trace: Trace? = null + + fun onLoadingStarted(providersCount: Int) { + trace?.stop() + trace = FirebasePerformance.getInstance().newTrace(SWAP_QUOTES_LOADED_TRACE_NAME).apply { + putAttribute(PROVIDERS_COUNT, providersCount.toString()) + start() + } + } + + fun onLoadingFinished(hasError: Boolean) { + trace?.apply { + putAttribute(HAS_ERROR, if (hasError) "Yes" else "No") + stop() + } + trace = null + } + + fun onDestroy() { + trace?.stop() + trace = null + } + + private companion object { + const val SWAP_QUOTES_LOADED_TRACE_NAME = "Swap_quotes_loaded" + const val PROVIDERS_COUNT = "providers_count" + const val HAS_ERROR = "has_error" + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt deleted file mode 100644 index e87799d55e..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.tangem.feature.swap.choosetoken.api - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup -import com.tangem.feature.swap.presentation.R -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow - -// todo swap make universal, encapsulate, move to some common module -internal interface ChooseTokenBridge { - - // todo swap new api - val onCurrencyChosen: Channel - val onClose: Channel - - // todo swap legacy api, remove - val onTokenSelected: Channel> - val onNewTokenAdded: Channel> - - val searchQueryState: StateFlow - val currenciesGroup: Flow - - fun onTokenSelected(tokenId: Pair) { - onTokenSelected.trySend(tokenId) - onSearchQuery("") - } - - fun onNewTokenAdded(addedToken: Pair) { - onNewTokenAdded.trySend(addedToken) - onSearchQuery("") - } - - fun onSearchQuery(query: String) - - fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) - - fun onCurrencyChosen(result: ChooseTokenResult) { - onCurrencyChosen.trySend(result) - } - - fun onClose() { - onClose.trySend(Unit) - onSearchQuery("") - } - - interface Factory { - fun create(modelScope: CoroutineScope): ChooseTokenBridge - } -} - -data class ChooseTokenResult( - val currency: CryptoCurrencyStatus, - val account: AccountStatus, - val wallet: UserWallet, - val analyticsPayload: Set = emptySet(), -) { - val walletId get() = wallet.walletId -} - -sealed interface ChooseTokenAnalyticsPayload { - - @Suppress("BooleanPropertyNaming") - @JvmInline - value class IsSearched(val value: Boolean) : ChooseTokenAnalyticsPayload - - @JvmInline - value class ScreensSources(val value: String) : ChooseTokenAnalyticsPayload -} - -internal interface ChooseTokenComponent : ComposableContentComponent { - - data class Params( - val bridge: ChooseTokenBridge, - val settings: Settings, - val analyticsPayload: Set = emptySet(), - ) - - data class Settings( - val title: TextReference, - val isShowMarketBlock: Boolean, - ) { - companion object { - val SwapFrom = Settings( - title = resourceReference(R.string.swapping_from_title), - isShowMarketBlock = false, - ) - val SwapTo = Settings( - title = resourceReference(R.string.swapping_to_title), - isShowMarketBlock = true, - ) - } - } - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt deleted file mode 100644 index 870a5a7abe..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.tangem.feature.swap.choosetoken.impl - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge -import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult -import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY -import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* - -internal class DefaultChooseTokenBridge @AssistedInject constructor( - @Assisted private val modelScope: CoroutineScope, -) : ChooseTokenBridge { - - override val onCurrencyChosen: Channel = Channel() - - override val onTokenSelected: Channel> = Channel() - override val onNewTokenAdded: Channel> = Channel() - override val onClose: Channel = Channel() - - private val onSearchQuery: Channel = Channel() - override val searchQueryState: StateFlow = onSearchQuery.receiveAsFlow() - .debounce(DEBOUNCE_SEARCH_DELAY) - .stateIn(modelScope, SharingStarted.Eagerly, initialValue = "") - - private val _currenciesGroupFlow = MutableStateFlow(null) - override val currenciesGroup: Flow = _currenciesGroupFlow.filterNotNull() - - override fun onSearchQuery(query: String) { - onSearchQuery.trySend(query) - } - - override fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) { - _currenciesGroupFlow.update { currenciesGroup } - } - - @AssistedFactory - interface Factory : ChooseTokenBridge.Factory { - override fun create(modelScope: CoroutineScope): DefaultChooseTokenBridge - } -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt deleted file mode 100644 index a130a0eabf..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.tangem.feature.swap.choosetoken.impl.converter - -import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter -import com.tangem.common.ui.account.TokensListPortfolioItemConverter -import com.tangem.common.ui.tokens.TokenConverterParams -import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems -import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems -import com.tangem.common.ui.tokens.TokenItemStateConverter -import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.tokenlist.TokenList -import com.tangem.feature.swap.choosetoken.impl.model.ClickIntents -import com.tangem.feature.swap.choosetoken.impl.model.isSearchingState -import com.tangem.feature.swap.models.TokenListUMData -import kotlinx.collections.immutable.toPersistentList - -internal class ChooseTokenListItemConverter( - private val appCurrency: AppCurrency, - private val params: TokenConverterParams, - private val clickIntents: ClickIntents, - private val searchQuery: String, -) { - - private val isSearchingState: Boolean get() = searchQuery.isSearchingState - - private val onTokenClick: (account: AccountStatus, currencyStatus: CryptoCurrencyStatus) -> Unit = - { account, currencyStatus -> - clickIntents.onTokenItemClick(account, currencyStatus) - } - - private fun tokenStatusConverter(account: AccountStatus) = TokenItemStateConverter( - appCurrency = appCurrency, - onItemClick = { _, status -> onTokenClick(account, status) }, - ) - - fun convert(): TokenListUMData { - return when (params) { - is TokenConverterParams.Account -> convertAccountList(params) - is TokenConverterParams.Wallet -> convertTokenList( - tokenConverter = tokenStatusConverter(params.mainAccount), - tokenListParam = params.tokenList, - ) - } - } - - private fun convertAccountList(params: TokenConverterParams.Account): TokenListUMData { - val accountList = params.accountList - val accountItems = accountList.accountStatuses - .filterCryptoPortfolio() - .map { accountStatus -> accountStatus.toPortfolioItem(params) } - .filter { portfolio -> portfolio.tokens.isNotEmpty() } - if (accountItems.isEmpty()) { - return TokenListUMData.EmptyList - } - val accountsList = accountItems.toPersistentList() - return TokenListUMData.AccountList( - tokensList = accountsList, - totalTokensCount = accountsList.size, - ) - } - - private fun AccountStatus.CryptoPortfolio.toPortfolioItem( - params: TokenConverterParams.Account, - ): TokensListItemUM.Portfolio { - val tokenList: TokenList = this.tokenList - val account: Account.CryptoPortfolio = this.account - val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId) - val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount -> - if (isExpanded) { - clickIntents.onAccountCollapseClick(clickedAccount) - } else { - clickIntents.onAccountExpandClick(clickedAccount) - } - } - val converter = AccountCryptoPortfolioItemStateConverter( - appCurrency = appCurrency, - account = account, - onItemClick = onItemClick.takeIf { !isSearchingState }, - priceChangeLce = this.priceChangeLce, - ) - val accountItem = converter.convert(tokenList.totalFiatBalance) - val tokenConverter = tokenStatusConverter(this) - val tokensListState = convertTokenList(tokenConverter, tokenList) - val items = tokensListState.tokensList - return TokensListPortfolioItemConverter( - tokenItemUM = accountItem, - isExpanded = isExpanded, - isCollapsable = !isSearchingState, - tokens = items.filterIsInstance().toPersistentList(), - ).convert(Unit) - } - - private fun convertTokenList(tokenConverter: TokenItemStateConverter, tokenListParam: TokenList): TokenListUMData { - val tokenList = if (isSearchingState) filterByQuery(tokenListParam) else tokenListParam - - return when (tokenList) { - is TokenList.Empty -> TokenListUMData.EmptyList - is TokenList.GroupedByNetwork -> tokenList.toGroupedItems(tokenConverter).let { grouped -> - TokenListUMData.TokenList( - tokensList = grouped.toPersistentList(), - totalTokensCount = grouped.size, - ) - } - is TokenList.Ungrouped -> tokenList.toUngroupedItems(tokenConverter).let { ungrouped -> - TokenListUMData.TokenList( - tokensList = ungrouped.toPersistentList(), - totalTokensCount = ungrouped.size, - ) - } - } - } - - private fun filterByQuery(tokenList: TokenList): TokenList { - fun List.filterByQuery(): List = filter { currency -> - currency.currency.name.contains(searchQuery, ignoreCase = true) || - currency.currency.symbol.contains(searchQuery, ignoreCase = true) - } - return when (tokenList) { - TokenList.Empty -> TokenList.Empty - is TokenList.Ungrouped -> { - val filtered = tokenList.currencies.filterByQuery() - if (filtered.isEmpty()) TokenList.Empty else tokenList.copy(currencies = filtered) - } - is TokenList.GroupedByNetwork -> { - val filteredGroups = tokenList.groups - .map { group -> - val filteredCurrencies = group.currencies.filterByQuery() - group.copy(currencies = filteredCurrencies) - } - .filter { group -> group.currencies.isNotEmpty() } - if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups) - } - } - } -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt deleted file mode 100644 index 2eb003236d..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ /dev/null @@ -1,262 +0,0 @@ -package com.tangem.feature.swap.choosetoken.impl.model - -import com.arkivanov.decompose.router.slot.dismiss -import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.account.AccountId -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.feature.swap.choosetoken.api.* -import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer -import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenFullUM -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenUM -import com.tangem.feature.swap.choosetoken.impl.ui.WalletListUM -import com.tangem.feature.swap.converters.TokensDataConverter -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* -import javax.inject.Inject - -internal val String.isSearchingState: Boolean get() = this.isNotBlank() -internal val StateFlow.isSearchingState: Boolean get() = this.value.isSearchingState - -@Suppress("LongParameterList") -@ModelScoped -internal class ChooseTokenModel @Inject constructor( - override val dispatchers: CoroutineDispatcherProvider, - private val settingContextUseCase: SettingContextUseCase, - private val getWalletsUseCase: GetWalletsUseCase, - portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory, - marketBlockDelegateFactory: MarketBlockDelegate.Factory, - paramsContainer: ParamsContainer, -) : Model() { - - private val params = paramsContainer.require() - private val bridge: ChooseTokenBridge = params.bridge - - private val searchQueryState: StateFlow = bridge.searchQueryState - private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState - private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create( - modelScope = modelScope, - searchQueryState = searchQueryState, - screensSourcesName = params.analyticsPayload - .filterIsInstance() - .firstOrNull()?.value.orEmpty(), - ) - private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create( - modelScope = modelScope, - searchQueryState = searchQueryState, - ) - - val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot - val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager - private val marketsStateFlow: Flow = if (params.settings.isShowMarketBlock) { - marketBlockDelegate.marketsStateFlow - } else { - flowOf(null) - } - - private val expandedAccountsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) - private val onWalletSelected = Channel() - - // todo swap call new api result - val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { - override fun onDismiss() = marketBlockDelegate.addToPortfolioSlot.dismiss() - - override fun onSuccess(addedToken: CryptoCurrency) { - val newToken = addedToken to ChooseTokenAnalyticsPayload.IsSearched(isSearchingState) - bridge.onNewTokenAdded(newToken) - marketBlockDelegate.addToPortfolioSlot.dismiss() - } - } - - val stateOld: StateFlow = combineUIOld() - - private val contentState: StateFlow = combineUI() - private val initialState: MutableStateFlow = MutableStateFlow(getInitState()) - val state: StateFlow = combine( - flow = initialState, - flow2 = contentState, - transform = { initial, content -> - ChooseTokenFullUM( - initialUM = initial, - contentUM = content, - ) - }, - ).stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = ChooseTokenFullUM(initialState.value, contentState.value), - ) - - @Suppress("UnusedPrivateMember") - private fun combineUIOld(): StateFlow = combine( - flow = bridge.currenciesGroup, - flow2 = settingContextUseCase.invoke(), - flow3 = marketsStateFlow, - flow4 = expandedAccountsFlow, - transform = { currenciesGroup, settingContext, marketState, expandedAccounts -> - val isAccountsMode = settingContext.isAccountsMode - val appCurrency = settingContext.appCurrency - val isBalanceHidden = settingContext.isBalanceHidden - - TokensDataConverter( - onSearchEntered = { query -> bridge.onSearchQuery(query) }, - onTokenClick = { tokenId -> - val selected = tokenId to ChooseTokenAnalyticsPayload - .IsSearched(isSearchingState) - bridge.onTokenSelected(selected) - }, - onAccountClick = { account -> - expandedAccountsFlow.update { expandedList -> - val hasSavedAccount = expandedList[account.accountId] - val isExpanded = hasSavedAccount == true - expandedList + (account.accountId to !isExpanded) - } - }, - expandedAccounts = expandedAccounts, - tokensDataState = currenciesGroup, - isBalanceHidden = isBalanceHidden, - isAccountsMode = isAccountsMode, - appCurrency = appCurrency, - marketState = requireNotNull(marketState), - ).transform() - }, - ) - .flowOn(dispatchers.default) - .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) - - @Suppress("LongMethod") - private fun combineUI(): StateFlow = channelFlow { - val allWalletsFlow: StateFlow> = - getWalletsUseCase.invokeAsMap().stateIn(this) - - val selectedWalletFlow: StateFlow = - onWalletSelected.receiveAsFlow() - .mapNotNull { walletId -> allWalletsFlow.value[walletId] } - .stateIn(this, SharingStarted.Eagerly, allWalletsFlow.value.values.first()) - - val selectedWalletTokensData: Flow = combine( - flow = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), - flow2 = portfolioListBlockDelegate.portfolioList, - transform = { selectedWalletId, allPortfoliosData -> allPortfoliosData[selectedWalletId] }, - ) - .filterNotNull() - .distinctUntilChanged() - - val walletListUmFlow = combine( - flow = selectedWalletFlow, - flow2 = allWalletsFlow, - transform = { selectedWallet, allWallets -> - allWallets.entries - .map { (walletId, wallet) -> - val type = if (selectedWallet.walletId == walletId) { - TangemButtonType.Primary - } else { - TangemButtonType.Secondary - } - TangemButtonUM( - text = stringReference(wallet.name), - onClick = { onWalletSelected.trySend(walletId) }, - type = type, - ) - } - }, - ) - .distinctUntilChanged() - - portfolioListBlockDelegate.onTokenItemClick.receiveAsFlow() - .onEach { (account, currencyStatus) -> - onTokenItemClick( - wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach, - account = account, - currencyStatus = currencyStatus, - ) - } - .launchIn(this) - - combine( - flow = selectedWalletTokensData, - flow2 = settingContextUseCase.invoke(), - flow3 = marketsStateFlow, - flow4 = walletListUmFlow, - transform = { tokensData, settings, marketsData, walletList -> - val walletsUM = if (walletList.size != 1) { - WalletListUM(walletList.toPersistentList()) - } else { - WalletListUM(persistentListOf()) - } - ChooseTokenUM( - walletList = walletsUM, - isBalanceHidden = settings.isBalanceHidden, - isSearching = isSearchingState, - tokensListData = tokensData, - marketsState = marketsData, - ) - }, - ) - .distinctUntilChanged() - .collect { newUM -> channel.send(newUM) } - } - .flowOn(dispatchers.default) - .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) - - private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) { - val analyticsPayload = setOf( - ChooseTokenAnalyticsPayload.IsSearched(isSearchingState), - ) - val result = ChooseTokenResult( - account = account, - currency = currencyStatus, - wallet = wallet, - analyticsPayload = analyticsPayload, - ) - bridge.onCurrencyChosen(result) - } - - fun onBackClicked() { - bridge.onClose() - } - - private fun getInitialSearchBar(): SearchBarUM = SearchBarUM( - placeholderText = resourceReference(R.string.common_search), - query = "", - isActive = false, - onQueryChange = { query -> - initialState.update { prevState -> SearchBarUpdateQueryTransformer(query).transform(prevState) } - bridge.onSearchQuery(query) - }, - onActiveChange = { isActive -> - initialState.update { prevState -> SearchBarToggleTransformer(isActive).transform(prevState) } - }, - ) - - private fun getInitState() = ChooseTokenInitialUM( - screenTitle = params.settings.title, - onCloseClick = ::onBackClicked, - searchBar = getInitialSearchBar(), - ) - - companion object { - const val DEBOUNCE_SEARCH_DELAY = 500L - } -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenUM.kt deleted file mode 100644 index 3f23f9cd85..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenUM.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.feature.swap.choosetoken.impl.ui - -import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.feature.swap.models.TokenListUMData -import com.tangem.feature.swap.models.market.state.SwapMarketState -import kotlinx.collections.immutable.ImmutableList - -internal data class ChooseTokenFullUM( - val initialUM: ChooseTokenInitialUM, - val contentUM: ChooseTokenUM?, -) - -internal data class ChooseTokenUM( - val walletList: WalletListUM, - val isBalanceHidden: Boolean, - val isSearching: Boolean, - val tokensListData: TokenListUMData, - val marketsState: SwapMarketState?, -) - -internal data class ChooseTokenInitialUM( - val screenTitle: TextReference, - val onCloseClick: () -> Unit, - val searchBar: SearchBarUM, -) - -internal data class WalletListUM( - val items: ImmutableList, -) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt index 0bb771929f..99118c8304 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt @@ -2,15 +2,21 @@ package com.tangem.feature.swap.converters import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount +import com.tangem.common.ui.R import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.account.TokensListPortfolioItemConverter +import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -29,34 +35,68 @@ internal class AccountTokenItemConverter( private val appCurrency: AppCurrency, private val unavailableErrorText: TextReference, private val expandedAccounts: Map, - private val onTokenItemClick: (String) -> Unit, - private val onAccountItemClick: (Account.CryptoPortfolio) -> Unit, + private val onTokenItemClick: (Account, CryptoCurrencyStatus) -> Unit, + private val onAccountItemClick: (Account) -> Unit, ) : Converter { override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio { - return TokensListPortfolioItemConverter( - tokenItemUM = AccountCryptoPortfolioItemStateConverter( + val headerTokenItemState = when (val account = value.account) { + is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, - account = value.account.copy( - cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }, - ), + account = account.copy(cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }), onItemClick = onAccountItemClick, ).convert( TotalFiatBalance.Loaded( amount = value.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() }, source = StatusSource.ONLY_CACHE, ), - ), + ) + is Account.Payment -> createPaymentAccountHeaderState(value) + } + return TokensListPortfolioItemConverter( + tokenItemUM = headerTokenItemState, isExpanded = expandedAccounts[value.account.accountId] != false, isCollapsable = true, tokens = value.currencyList.map { accountSwapCurrency -> - createAvailableItemConverter() + createAvailableItemConverter(value.account) .convert(accountSwapCurrency.cryptoCurrencyStatus) }.map(TokensListItemUM::Token).toPersistentList(), ).convert(Unit) } - fun createAvailableItemConverter(): TokenItemStateConverter { + private fun createPaymentAccountHeaderState(accountSwapAvailability: AccountSwapAvailability): TokenItemState { + val account = accountSwapAvailability.account + val tokensCount = accountSwapAvailability.currencyList.size + val fiatBalance = + accountSwapAvailability.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() } + return TokenItemState.Content( + id = account.accountId.value, + iconState = CurrencyIconState.PaymentAccount(), + titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = { onAccountItemClick(account) }, + fiatAmountState = FiatAmountState.Content( + text = fiatBalance.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + isFlickering = false, + ), + subtitle2State = null, + onItemLongClick = null, + ) + } + + fun createAvailableItemConverter(account: Account): TokenItemStateConverter { return TokenItemStateConverter( appCurrency = appCurrency, subtitleStateProvider = { status -> @@ -70,7 +110,7 @@ internal class AccountTokenItemConverter( fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true) }, - onItemClick = { account, currencyStatus -> onTokenItemClick(currencyStatus.currency.id.value) }, + onItemClick = { _, currencyStatus -> onTokenItemClick(account, currencyStatus) }, ) } @@ -140,14 +180,14 @@ internal class AccountTokenItemConverter( status: CryptoCurrencyStatus, appCurrency: AppCurrency, isAvailable: Boolean, - ): TokenItemState.FiatAmountState? { + ): FiatAmountState? { return when (status.value) { is CryptoCurrencyStatus.Loaded, is CryptoCurrencyStatus.Custom, is CryptoCurrencyStatus.NoQuote, is CryptoCurrencyStatus.NoAccount, -> { - TokenItemState.FiatAmountState.TextContent( + FiatAmountState.TextContent( text = status.getTotalFiatAmount().format { fiat( fiatCurrencyCode = appCurrency.code, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt deleted file mode 100644 index 64ba7cf5b0..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.tangem.feature.swap.converters - -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountId -import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.plus -import kotlinx.collections.immutable.toPersistentList - -@Suppress("LongParameterList") -internal class TokensDataConverter( - private val onSearchEntered: (String) -> Unit, - onTokenClick: (String) -> Unit, - onAccountClick: (Account.CryptoPortfolio) -> Unit, - private val expandedAccounts: Map, - private val tokensDataState: CurrenciesGroup, - private val isBalanceHidden: Boolean, - private val isAccountsMode: Boolean, - private val appCurrency: AppCurrency, - private val marketState: SwapMarketState, -) { - - private val accountListItemConverter = AccountTokenItemConverter( - appCurrency = appCurrency, - unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), - onTokenItemClick = onTokenClick, - onAccountItemClick = onAccountClick, - expandedAccounts = expandedAccounts, - ) - - fun transform(): SwapSelectTokenStateHolder { - val accountList = tokensDataState.accountCurrencyList - return SwapSelectTokenStateHolder( - tokensListData = if (isAccountsMode) { - val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList() - val totalTokensCount = portfolioList.sumOf { it.tokens.size } - if (totalTokensCount > 0) { - TokenListUMData.AccountList( - tokensList = portfolioList, - totalTokensCount = totalTokensCount, - ) - } else { - TokenListUMData.EmptyList - } - } else { - val tokensList = accountList.flatMap { (_, currencyList) -> - currencyList.asSequence().map { accountSwapCurrency -> - accountListItemConverter.createAvailableItemConverter() - .convert(accountSwapCurrency.cryptoCurrencyStatus) - }.map(TokensListItemUM::Token).toPersistentList() - }.toPersistentList() - - if (tokensList.isNotEmpty()) { - TokenListUMData.TokenList( - tokensList = persistentListOf( - TokensListItemUM.GroupTitle( - id = "available_tokens_title", - text = resourceReference(R.string.exchange_tokens_available_tokens_header), - ), - ) + tokensList, - totalTokensCount = tokensList.size, - ) - } else { - TokenListUMData.EmptyList - } - }, - marketsState = marketState, - onSearchEntered = onSearchEntered, - isBalanceHidden = isBalanceHidden, - isAfterSearch = tokensDataState.isAfterSearch, - ) - } -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt index 5cf4ea502f..990f8c8ec9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.di -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.feature.swap.DefaultSwapComponent import com.tangem.feature.swap.DefaultSwapFeatureToggles import com.tangem.features.swap.SwapComponent @@ -18,8 +17,8 @@ internal object SwapFeatureModule { @Provides @Singleton - fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles { - return DefaultSwapFeatureToggles(featureTogglesManager) + fun provideSwapFeatureToggles(): SwapFeatureToggles { + return DefaultSwapFeatureToggles() } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt new file mode 100644 index 0000000000..a245cc7a9f --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt @@ -0,0 +1,225 @@ +package com.tangem.feature.swap.model + +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition +import com.tangem.utils.extensions.orZero +import com.tangem.utils.isNullOrZero +import javax.inject.Inject + +/** + * Resolves the initial FROM and TO currencies when the swap screen opens. + * + * Selection rules when [initialCryptoCurrency][CryptoCurrency] is provided: + * - [CurrencyPosition.FROM] — places the currency as FROM, TO is null. + * - [CurrencyPosition.TO] — places the currency as TO, FROM is null. + * - [CurrencyPosition.ANY] — auto-places based on availability and balance: + * - available with balance → FROM. + * - available without balance or unavailable without balance → TO, + * and the best candidate from the SAME account as the initial currency is selected as FROM + * (the search is scoped to that account only, not the whole portfolio). + * - unavailable with balance → FROM. + * + * When no initial currency is provided, selects the best token from crypto portfolio accounts: + * 1. If available tokens with balance exist — the available token with the highest fiat balance. + * 2. If available tokens exist but none have balance — the first token from the first account. + * 3. If no available tokens exist but tokens with balance exist — the token with the highest fiat balance. + * 4. If no tokens have balance — the first token from the first account. + */ +internal class InitialCurrenciesResolver @Inject constructor( + private val getUserWalletUseCase: GetUserWalletUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val rampStateManager: RampStateManager, +) { + + /** + * Resolves the initial FROM/TO currency pair for the swap screen. + * + * @param userWalletId the wallet to resolve currencies for + * @param initialCryptoCurrency pre-selected currency, or null to auto-select + * @param swapCurrencyPosition preferred position for the initial currency + * @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null + */ + suspend operator fun invoke( + userWalletId: UserWalletId, + initialCryptoCurrency: CryptoCurrency?, + swapCurrencyPosition: CurrencyPosition, + isPaymentAccount: Boolean, + ): Pair { + val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId) + val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus -> + accountStatus is AccountStatus.CryptoPortfolio + }.mapKeys { (key, _) -> key as AccountStatus.CryptoPortfolio } + val cryptoPaymentAccounts = walletAccountList.filterKeys { accountStatus -> + accountStatus is AccountStatus.Payment + } + + val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten() + + return if (initialCryptoCurrency != null) { + val selectedSwapCurrencyStatus = if (isPaymentAccount) { + cryptoPaymentAccounts + } else { + cryptoPortfolioAccounts + }.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.firstOrNull { currencyStatus -> + currencyStatus.currency.id == initialCryptoCurrency.id + } + } + + if (selectedSwapCurrencyStatus == null) { + null to null + } else { + placeSelectedCurrency( + selectedSwapCurrencyStatus = selectedSwapCurrencyStatus, + swapCurrencyPosition = swapCurrencyPosition, + cryptoPortfolioAccountsMap = cryptoPortfolioAccounts, + ) + } + } else { + selectCryptoCurrency( + cryptoPortfolioAccountsMap = cryptoPortfolioAccounts, + cryptoCurrencyList = cryptoCurrencyList, + ) to null + } + } + + /** + * Builds a map of [AccountStatus] to their [SwapCurrencyStatus] lists, + * enriching each currency with its swap availability from [RampStateManager]. + */ + private suspend fun getWalletAccountCurrencyStatusList( + userWalletId: UserWalletId, + ): Map> { + val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return emptyMap() + + val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(userWalletId), + )?.accountStatuses.orEmpty() + + return walletAccountCurrencyStatuses.associateWith { accountStatus -> + val currencyStatuses = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies() + is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) + } + val availabilityStates = rampStateManager.availableForSwap( + userWalletId, + currencyStatuses.map { it.currency }, + ) + currencyStatuses.map { cryptoCurrencyStatus -> + SwapCurrencyStatus( + userWallet = userWallet, + account = accountStatus.account, + status = cryptoCurrencyStatus, + isAvailableForSwap = availabilityStates[cryptoCurrencyStatus.currency] == + ScenarioUnavailabilityReason.None, + ) + } + } + } + + private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { + val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + else -> null + } + + return listOfNotNull(paymentCryptoCurrencyStatus) + } + + /** + * Places the [selectedSwapCurrencyStatus] into the FROM or TO slot based on [swapCurrencyPosition]. + * + * For [CurrencyPosition.ANY], the position is determined by availability and balance: + * currencies that are available with balance go to FROM; otherwise, the selected currency + * goes to TO and a best-candidate FROM is resolved via [selectCryptoCurrency] — scoped to the + * SAME account that the selected currency belongs to, so we never pull a FROM candidate from a + * different account in the portfolio. + */ + private fun placeSelectedCurrency( + selectedSwapCurrencyStatus: SwapCurrencyStatus, + swapCurrencyPosition: CurrencyPosition, + cryptoPortfolioAccountsMap: Map>, + ): Pair { + return when (swapCurrencyPosition) { + CurrencyPosition.FROM -> { + selectedSwapCurrencyStatus to null + } + CurrencyPosition.TO -> { + null to selectedSwapCurrencyStatus + } + CurrencyPosition.ANY -> { + val isAvailable = selectedSwapCurrencyStatus.isAvailableForSwap + val hasBalance = !selectedSwapCurrencyStatus.status.value.fiatAmount.isNullOrZero() + if (isAvailable && hasBalance) { + selectedSwapCurrencyStatus to null + } else if (isAvailable || !hasBalance) { + val selectedCurrency = selectedSwapCurrencyStatus.currency + val selectedAccountId = selectedSwapCurrencyStatus.account.accountId + val sameAccountEntry = cryptoPortfolioAccountsMap.entries + .firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == selectedAccountId } + + if (sameAccountEntry == null) { + null to selectedSwapCurrencyStatus + } else { + val scopedList = sameAccountEntry.value + .filterNot { it.currency.isSameTokenAs(selectedCurrency) } + selectCryptoCurrency( + cryptoPortfolioAccountsMap = mapOf(sameAccountEntry.key to scopedList), + cryptoCurrencyList = scopedList, + ) to selectedSwapCurrencyStatus + } + } else { + selectedSwapCurrencyStatus to null + } + } + } + } + + /** + * Checks whether two currencies refer to the same asset on the same network, regardless of the + * owning account. Two instances of the same token in different accounts have distinct + * [CryptoCurrency.ID] values (their derivation path differs), so id equality is not sufficient + * to detect duplicates when auto-picking a FROM candidate. + */ + private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean { + return id.rawNetworkId == other.id.rawNetworkId && + id.contractAddress == other.id.contractAddress + } + + /** + * Selects the best token from the crypto portfolio when no initial currency is specified. + * + * Prioritizes available-for-swap tokens. Among the candidates, picks the one with the highest + * [fiatAmount][CryptoCurrencyStatus.Value.fiatAmount]. Falls back to the first token from the + * first account if no candidate has a positive balance. + */ + private fun selectCryptoCurrency( + cryptoPortfolioAccountsMap: Map>, + cryptoCurrencyList: List, + ): SwapCurrencyStatus? { + return if (cryptoCurrencyList.isEmpty()) { + null + } else { + val hasAvailable = cryptoCurrencyList.any { it.isAvailableForSwap } + val candidates = if (hasAvailable) { + cryptoCurrencyList.filter { it.isAvailableForSwap } + } else { + cryptoCurrencyList + } + candidates + .filter { !it.status.value.fiatAmount.isNullOrZero() } + .maxByOrNull { it.status.value.fiatAmount.orZero() } + ?: cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull() + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index e0328dd545..a1ea402b4b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -10,34 +10,31 @@ import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources import com.tangem.core.analytics.models.Basic -import com.tangem.core.analytics.models.event.SwapAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore -import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus -import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -49,18 +46,20 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.promo.ShouldShowStoriesUseCase import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions -import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase @@ -69,9 +68,8 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.analytics.SwapEvents -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge +import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.domain.AllowPermissionsHandler @@ -79,29 +77,36 @@ import com.tangem.feature.swap.domain.SwapInteractor import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.TxFeeSealedState import com.tangem.feature.swap.domain.models.ExpressDataError -import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.SwapAlertUM +import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.TokenSelectionDirection +import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.SwapNotificationUM -import com.tangem.feature.swap.router.SwapNavScreen -import com.tangem.feature.swap.router.SwapRouter +import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.feature.swap.utils.getContractAddress import com.tangem.features.approval.api.GiveApprovalComponent -import com.tangem.features.approval.api.GiveApprovalFeatureToggles +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.SwapComponent import com.tangem.utils.Provider -import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.* +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -111,91 +116,85 @@ import javax.inject.Inject typealias SuccessLoadedSwapData = Map -@OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class SwapModel @Inject constructor( paramsContainer: ParamsContainer, + getUserCountryUseCase: GetUserCountryUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + chooseTokenBridgeFactory: ChooseTokenBridge.Factory, + private val router: Router, + private val appRouter: AppRouter, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsErrorEventHandler: AnalyticsErrorHandler, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, - private val getUserWalletUseCase: GetUserWalletUseCase, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, - getUserCountryUseCase: GetUserCountryUseCase, - getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - swapInteractorFactory: SwapInteractor.Factory, - private val urlOpener: UrlOpener, - router: AppRouter, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val swapInteractor: SwapInteractor, + private val urlOpener: UrlOpener, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, - private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, + private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val appsFlyerStore: AppsFlyerStore, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, private val messageSender: UiMessageSender, + private val initialCurrenciesResolver: InitialCurrenciesResolver, private val allowPermissionsHandler: AllowPermissionsHandler, - chooseTokenBridgeFactory: ChooseTokenBridge.Factory, - giveApprovalFeatureToggles: GiveApprovalFeatureToggles, ) : Model() { private val params = paramsContainer.require() - private val initialCurrencyFrom = params.currencyFrom - private val initialCurrencyTo = params.currencyTo - private val userWalletId = params.userWalletId - private val isInitiallyReversed = params.isInitialReverseOrder + private val initialCryptoCurrency = params.cryptoCurrency private val tangemPayInput = params.tangemPayInput - private val userWallet by lazy { - requireNotNull( - getUserWalletUseCase(userWalletId).getOrNull(), - ) { "No wallet found for id: $userWalletId" } - } - private val swapInteractor = swapInteractorFactory.create(userWalletId) - - val isHoldToConfirmEnabled: Boolean = - holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWallet.isHotWallet - - private lateinit var initialFromStatus: CryptoCurrencyStatus - private var initialToStatus: CryptoCurrencyStatus? = null - private var isBalanceHidden = true + private var isAccountsMode: Boolean = false private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() - val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(modelScope) + val chooseFromTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings.SwapFrom, + analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), + ), + ) + val chooseToTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings.SwapTo, + analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), + ), + ) private val stateBuilder = StateBuilder( - userWalletProvider = Provider { userWallet }, actions = createUiActions(), isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, - holdToConfirmButtonFeatureToggles = holdToConfirmButtonFeatureToggles, ) - private val inputNumberFormatter = - InputNumberFormatter( - NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat - ?: error("NumberFormat is not DecimalFormat"), - ) + private val inputNumberFormatter = InputNumberFormatter( + NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat + ?: error("NumberFormat is not DecimalFormat"), + ) + private val amountDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() + private val performanceTracker = SwapQuotePerformanceTracker() val dataStateStateFlow = MutableStateFlow(SwapProcessDataState()) var dataState @@ -204,35 +203,15 @@ internal class SwapModel @Inject constructor( dataStateStateFlow.value = value } - var uiState: SwapStateHolder by mutableStateOf( - stateBuilder.createInitialLoadingState( - initialCurrencyFrom = initialCurrencyFrom, - initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), - ), - ) + var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState()) private set val feeSelectorRepository = FeeSelectorRepository() - // shows currency order (direct - swap initial to selected, reversed = selected to initial) - private val isOrderReversed = MutableStateFlow(value = params.isInitialReverseOrder) private val lastAmount = mutableStateOf(INITIAL_AMOUNT) private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO) - private val swapRouter: SwapRouter = SwapRouter(router = router) private var userCountry: UserCountry? = null - private var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null - private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null - - /** - * If user came from Tangem Pay -> fromAccountCurrencyStatus == null - * If user didn't come from Tangem Pay -> fromAccountCurrencyStatus != null - * - * Remove when accounts are integrated into Tangem Pay - */ - private val canUseFromAccountCurrencyStatus = tangemPayInput == null - private val isUserResolvableError: (SwapState) -> Boolean = { swapState -> swapState is SwapState.SwapError && ( @@ -247,19 +226,13 @@ internal class SwapModel @Inject constructor( private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null - val currentScreen: SwapNavScreen - get() = swapRouter.currentScreen - val approvalSlotNavigation = SlotNavigation() - private val shouldUseGaslessApproval: Boolean = giveApprovalFeatureToggles.isGaslessApprovalEnabled val approvalCallback = object : GiveApprovalComponent.Callback { - override fun onApproveClick() { - sendPermissionApproveClickedEvent() - } + override fun onApproveClick() {} override fun onApproveDone() { - val fromContractAddress = dataState.fromCryptoCurrency?.currency?.getContractAddress() + val fromContractAddress = dataState.fromSwapCurrencyStatus?.currency?.getContractAddress() if (fromContractAddress != null) { allowPermissionsHandler.addAddressToInProgress(fromContractAddress) } @@ -277,33 +250,11 @@ internal class SwapModel @Inject constructor( override fun onCancelClick() { approvalSlotNavigation.dismiss() startLoadingQuotesFromLastState(isSilent = true) - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked()) } } init { - chooseTokenBridge.searchQueryState - .onEach { query -> onSearchEntered(query) } - .launchIn(modelScope) - - chooseTokenBridge.onNewTokenAdded.receiveAsFlow() - .onEach { (addedToken, isSearched) -> - applyAddedToken(addedToken, isSearched.value) - } - .launchIn(modelScope) - - chooseTokenBridge.onTokenSelected.receiveAsFlow() - .onEach { (addedToken, isSearched) -> - onTokenSelect(addedToken, isSearched.value) - } - .launchIn(modelScope) - - chooseTokenBridge.onClose.receiveAsFlow() - .onEach { - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) - swapRouter.back() - } - .launchIn(modelScope) + subscribeToTokenSelection() modelScope.launch { val storyId = StoryContentIds.STORY_FIRST_TIME_SWAP.id @@ -321,63 +272,20 @@ internal class SwapModel @Inject constructor( userCountry = getUserCountryUseCase.invokeSync().getOrNull() ?: UserCountry.Other(Locale.getDefault().country) - modelScope.launch(dispatchers.io) { - if (canUseFromAccountCurrencyStatus) { - isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() - - val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWalletId, - currency = initialCurrencyFrom, - ).getOrNull() - val toAccountStatus = initialCurrencyTo?.let { currencyTo -> - getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWalletId, - currency = currencyTo, - ).getOrNull() - } - - if (fromAccountStatus == null) { - showAlert() - swapRouter.back() - } else { - fromAccountCurrencyStatus = fromAccountStatus - toAccountCurrencyStatus = toAccountStatus - initialFromStatus = fromAccountStatus.status - initialToStatus = toAccountStatus?.status - initTokens(isInitiallyReversed) - } - } else { - val fromStatus = getFromStatus() - val toStatus = initialCurrencyTo?.let { currencyTo -> - singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) - .getCryptoCurrencyStatus(currencyTo) - .getOrNull() - } - - if (fromStatus == null) { - showAlert() - swapRouter.back() - } else { - initialFromStatus = fromStatus - initialToStatus = toStatus - initTokens(isInitiallyReversed) - } - } - } + initTokens() + // TODO swap analytics analyticsEventHandler.send( SwapEvents.SwapScreenOpened( - token = initialCurrencyFrom.symbol, - blockchain = initialCurrencyFrom.network.name, + token = initialCryptoCurrency?.symbol.orEmpty(), + blockchain = initialCryptoCurrency?.network?.name.orEmpty(), ), ) - getBalanceHidingSettingsUseCase() - .onEach { settings -> - isBalanceHidden = settings.isBalanceHidden - uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) - } - .launchIn(modelScope) + getBalanceHidingSettingsUseCase().onEach { settings -> + isBalanceHidden = settings.isBalanceHidden + uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) + }.launchIn(modelScope) } fun onStart() { @@ -390,226 +298,302 @@ internal class SwapModel @Inject constructor( override fun onDestroy() { singleTaskScheduler.cancelTask() + performanceTracker.onDestroy() super.onDestroy() } - private fun sendSelectTokenScreenOpenedEvent() { - val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() == true - val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() == true - val isAnyAvailableAccountTokensTo = !dataState.tokensDataState?.toGroup?.accountCurrencyList.isNullOrEmpty() - val isAnyAvailableAccountTokensFrom = !dataState.tokensDataState?.fromGroup?.accountCurrencyList.isNullOrEmpty() - val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom || - isAnyAvailableAccountTokensTo || isAnyAvailableAccountTokensFrom - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens)) + private fun subscribeToTokenSelection() { + chooseFromTokenBridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> + onTokenSelect(result, isFromDirection = true) + } + .launchIn(modelScope) + + chooseFromTokenBridge.onClose.receiveAsFlow() + .onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + } + .launchIn(modelScope) + + chooseToTokenBridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> + onTokenSelect(result, isFromDirection = false) + } + .launchIn(modelScope) + + chooseToTokenBridge.onClose.receiveAsFlow() + .onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + } + .launchIn(modelScope) + } + + private fun initTokens() { + modelScope.launch(dispatchers.default) { + isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() + + val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = initialCurrenciesResolver( + userWalletId = params.userWalletId, + initialCryptoCurrency = initialCryptoCurrency, + swapCurrencyPosition = params.currencyPosition, + isPaymentAccount = params.tangemPayInput != null, + ) + + dataState = dataState.copy( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + + selectWalletInSelector( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + filterTokensFromSelector() + + if (fromSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) + subscribeToCoinBalanceUpdatesIfNeeded() + } + + uiState = stateBuilder.createInitialReadyState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + + // Check swap availability if there is pair + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + initSwapPairs( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + } } @Suppress("LongMethod") - private fun initTokens(isReverseFromTo: Boolean) { - modelScope.launch(dispatchers.main) { - runCatching(dispatchers.io) { - swapInteractor.getTokensDataState(initialCurrencyFrom) - }.onSuccess { state -> - updateTokensState(state) + private suspend fun onTokenSelect(result: ChooseTokenResult, isFromDirection: Boolean) { + val selectedUserWallet = result.wallet + val selectedCurrencyStatus = result.currency + val selectedAccount = result.account.account - val (selectedCurrency, selectedAccount) = run { - var selectedAccountCurrency = toAccountCurrencyStatus + val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = if (isFromDirection) { + SwapCurrencyStatus( + userWallet = selectedUserWallet, + status = selectedCurrencyStatus, + account = selectedAccount, + ) to dataState.toSwapCurrencyStatus + } else { + dataState.fromSwapCurrencyStatus to SwapCurrencyStatus( + userWallet = selectedUserWallet, + status = selectedCurrencyStatus, + account = selectedAccount, + ) + } - if (selectedAccountCurrency == null) { - val amountSwapCurrency = swapInteractor.getInitialCurrencyToSwap( - initialCryptoCurrency = initialCurrencyFrom, - state = state, - isReverseFromTo = isReverseFromTo, + if (dataState.fromSwapCurrencyStatus != null) { + isAmountChangedByUser = true + } + + // Check whether pair was already selected + if (fromSwapCurrencyStatus == dataState.fromSwapCurrencyStatus && + toSwapCurrencyStatus == dataState.toSwapCurrencyStatus + ) { + startLoadingQuotesFromLastState(true) + return + } + + dataState = if (isFromDirection) { + // Reset amount if from token is changed + lastAmount.value = INITIAL_AMOUNT + lastReducedBalanceBy.value = BigDecimal.ZERO + SwapProcessDataState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } else { + dataState.copy( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + filterTokensFromSelector() + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + shouldResetAmount = isFromDirection, + ) - if (amountSwapCurrency != null) { - selectedAccountCurrency = AccountCryptoCurrencyStatus( - account = amountSwapCurrency.account, - status = amountSwapCurrency.cryptoCurrencyStatus, + router.pop() + + if (isFromDirection && fromSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) + } + + subscribeToCoinBalanceUpdatesIfNeeded() + + // Check swap availability if there is pair + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + initSwapPairs( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + } + + private fun onChangeCardsClicked() { + modelScope.launch { + singleTaskScheduler.cancelTask() + + val newFromSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val newToSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + + isAmountChangedByUser = true + + lastAmount.value = INITIAL_AMOUNT + lastReducedBalanceBy.value = BigDecimal.ZERO + + dataState = SwapProcessDataState( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, + selectedPairProviders = dataState.selectedPairProviders, + ) + filterTokensFromSelector() + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, ) - } - } + }, + ), + ), + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + shouldResetAmount = true, + ) - selectedAccountCurrency?.status to selectedAccountCurrency?.account - } - - val isApplied = applyInitialTokenChoice( - state = state, - selectedCurrency = selectedCurrency, - selectedAccount = selectedAccount, - isReverseFromTo = isReverseFromTo, + if (newFromSwapCurrencyStatus != null && newToSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(newFromSwapCurrencyStatus) + val toProvidersList = swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, ) - - // assume that fromCryptoCurrency selected according reverse flag, - // so update fee paid currency according to it - val fromCryptoCurrency = dataState.fromCryptoCurrency - - if (isApplied && fromCryptoCurrency != null) { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " + - "isReverseFromTo: $isReverseFromTo", + if (toProvidersList.isEmpty()) { + handleSwapNotSupported( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, ) - updateFeePaidCryptoCurrencyFor(fromCryptoCurrency) } else { - TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null") - } - - subscribeToCoinBalanceUpdatesIfNeeded() - }.onFailure { error -> - TangemLogger.e("Error", error) - - applyInitialTokenChoice( - state = TokensDataStateExpress.EMPTY, - selectedCurrency = null, - selectedAccount = null, - isReverseFromTo = isReverseFromTo, - ) - - uiState = stateBuilder.createInitialErrorState( - uiState, - (error as? ExpressException)?.expressDataError?.code ?: ExpressDataError.UnknownError.code, - ) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = initialCurrencyFrom, - initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), + startLoadingQuotes( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + toProvidersList = toProvidersList, ) - initTokens(isReverseFromTo) } } } } - private suspend fun applyAddedToken(addedToken: CryptoCurrency, isSearched: Boolean) { - analyticsEventHandler.send( - SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = addedToken.symbol), - ) - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = addedToken.symbol, - source = ScreensSources.Markets, - isSearched = isSearched, - ), - ) - val status = getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken) - // todo swap are sure?? about status.value is CryptoCurrencyStatus.Loaded - .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded } - ?: return - val (selectedAccount, selectedCurrency) = status - - runCatching(dispatchers.io) { - swapInteractor.getTokensDataState(initialCurrencyFrom) - }.onSuccess { state -> - updateTokensState(state) - - applyInitialTokenChoice( - state = state, - selectedCurrency = selectedCurrency, - selectedAccount = selectedAccount, - isReverseFromTo = isOrderReversed.value, + private fun initSwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { + modelScope.launch { + swapInteractor.getPair( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + filterProviderTypes = if (tangemPayInput?.isWithdrawal == true) { + listOf(ExchangeProviderType.CEX) + } else { + ExchangeProviderType.getSwapProviderTypes() + }, + ).fold( + ifLeft = { error -> + handleSwapNotSupported( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + TangemLogger.e("Error getting swap pair", error) + }, + ifRight = { pairs -> + val providerList = swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + pairs = pairs, + ) + if (providerList.isEmpty()) { + handleSwapNotSupported( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } else { + dataState = dataState.copy( + pairs = pairs, + selectedPairProviders = providerList, + ) + startLoadingQuotes( + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + toProvidersList = providerList, + ) + } + }, ) - - subscribeToCoinBalanceUpdatesIfNeeded() - - swapRouter.back() - }.onFailure { error -> - TangemLogger.e("Error", error) } } + @Suppress("UnusedPrivateMember") private fun subscribeToCoinBalanceUpdatesIfNeeded() { - (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + if (fromSwapCurrencyStatus != null) { subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, + swapCurrencyStatus = fromSwapCurrencyStatus, isFromCurrency = true, ) } - (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + if (toSwapCurrencyStatus != null) { subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, + swapCurrencyStatus = toSwapCurrencyStatus, isFromCurrency = false, ) } } - /** - * returns true if tokens are selected and dataState is updated, - * false if selected token is null and alert is shown with error message - */ - private fun applyInitialTokenChoice( - state: TokensDataStateExpress, - selectedCurrency: CryptoCurrencyStatus?, - selectedAccount: Account.CryptoPortfolio?, - isReverseFromTo: Boolean, - ): Boolean { - // exceptional case - if (selectedCurrency == null) { - TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}") - analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap()) - uiState = stateBuilder.createNoAvailableTokensToSwapState( - uiStateHolder = uiState, - fromToken = initialFromStatus, - ) - return false - } - isOrderReversed.value = isReverseFromTo - val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) { - selectedCurrency to initialFromStatus - } else { - initialFromStatus to selectedCurrency - } - val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { - if (isOrderReversed.value) { - selectedAccount to requireNotNull(fromAccountCurrencyStatus).account - } else { - requireNotNull(fromAccountCurrencyStatus).account to selectedAccount - } - } else { - null to null - } - dataState = dataState.copy( - fromCryptoCurrency = fromCurrencyStatus, - fromAccount = fromAccount, - toCryptoCurrency = toCurrencyStatus, - toAccount = toAccount, - tokensDataState = state, - ) - - if (handleSwapNotSupported( - state = state, - fromToken = fromCurrencyStatus, - toToken = toCurrencyStatus, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return true - } - - startLoadingQuotes( - fromToken = fromCurrencyStatus, - fromAccount = fromAccount, - toToken = toCurrencyStatus, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus), - ) - return true - } - - private fun updateTokensState(tokenDataState: TokensDataStateExpress) { - val tokensDataState = if (isOrderReversed.value) tokenDataState.fromGroup else tokenDataState.toGroup - chooseTokenBridge.updateCurrenciesGroup(tokensDataState) - } - private fun startLoadingQuotes( - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -617,24 +601,21 @@ internal class SwapModel @Inject constructor( updateFeeBlock: Boolean = true, ) { singleTaskScheduler.cancelTask() + if (amount.isBlank()) return if (!isSilent) { uiState = stateBuilder.createQuotesLoadingState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, uiStateHolder = uiState, - fromToken = fromToken.currency, - toToken = toToken.currency, - fromAccount = fromAccount, - toAccount = toAccount, - mainTokenId = initialCurrencyFrom.id.value, ) feeSelectorRepository.state.value = FeeSelectorUM.Loading + performanceTracker.onLoadingStarted(toProvidersList.size) } singleTaskScheduler.scheduleTask( modelScope, loadQuotesTask( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, toProvidersList = toProvidersList, @@ -644,45 +625,51 @@ internal class SwapModel @Inject constructor( } private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) { - val fromCurrency = dataState.fromCryptoCurrency - val toCurrency = dataState.toCryptoCurrency + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus val amount = dataState.amount - if (fromCurrency != null && toCurrency != null && amount != null) { + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null && amount != null) { startLoadingQuotes( - fromToken = fromCurrency, - fromAccount = dataState.fromAccount, - toToken = toCurrency, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, isSilent = isSilent, reduceBalanceBy = dataState.reduceBalanceBy, - toProvidersList = findSwapProviders(fromCurrency, toCurrency), + toProvidersList = dataState.selectedPairProviders, updateFeeBlock = updateFeeBlock, ) } } - private suspend fun updateFeePaidCryptoCurrencyFor(fromToken: CryptoCurrencyStatus) { - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = fromToken, - ) - .onLeft { TangemLogger.e("Unable to get fee paid crypto currency status for ${fromToken.currency.id}") } - .onRight { currencyStatus -> - if (currencyStatus == null) { - TangemLogger.e("Fee paid crypto currency status is null for ${fromToken.currency.id}") - } + private suspend fun updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus: SwapCurrencyStatus) { + val fromCryptoCurrency = fromSwapCurrencyStatus.currency + val feePaidCryptoCurrency = if (fromSwapCurrencyStatus.account is Account.Payment) { + fromSwapCurrencyStatus.status + } else { + getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, + ).onLeft { + TangemLogger.e( + messageString = "Unable to get fee paid crypto currency status for ${fromCryptoCurrency.id}", + shouldSanitize = false, + ) + }.onRight { currencyStatus -> + if (currencyStatus == null) { + TangemLogger.e( + messageString = "Fee paid crypto currency status is null for ${fromCryptoCurrency.id}", + shouldSanitize = false, + ) } - .getOrNull(), - ) + }.getOrNull() + } + + dataState = dataState.copy(feePaidCryptoCurrency = feePaidCryptoCurrency) } private fun loadQuotesTask( - fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -698,13 +685,10 @@ internal class SwapModel @Inject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, swapDataModel = null, - approveDataModel = null, ) swapInteractor.findBestQuote( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, @@ -713,9 +697,17 @@ internal class SwapModel @Inject constructor( } }, onSuccess = { providersState -> + performanceTracker.onLoadingFinished( + hasError = providersState.values.none { it is SwapState.QuotesLoadedState }, + ) if (providersState.isNotEmpty()) { val (provider, state) = updateLoadedQuotes(providersState) - setupLoadedState(provider = provider, state = state, fromToken = fromToken, toToken = toToken) + setupLoadedState( + provider = provider, + state = state, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) val successStates = providersState.getLastLoadedSuccessStates() val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) uiState = stateBuilder.updateProvidersBottomSheetContent( @@ -730,12 +722,14 @@ internal class SwapModel @Inject constructor( shouldUpdateFeeBlock = true } } else { - feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) + feeSelectorRepository.state.value = + FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) TangemLogger.e("Accidentally empty quotes list") } }, onError = { error -> - TangemLogger.e("Error when loading quotes: $error") + TangemLogger.e("Error when loading quotes", error) + performanceTracker.onLoadingFinished(hasError = true) feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } }, @@ -745,17 +739,17 @@ internal class SwapModel @Inject constructor( private fun setupLoadedState( provider: SwapProvider, state: SwapState, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, ) { when (state) { is SwapState.QuotesLoadedState -> { - setupQuotesLoadedUiState(provider, state, fromToken) - sendAnalyticsForNotifications(provider, fromToken, toToken) + setupQuotesLoadedUiState(provider, state) + sendAnalyticsForNotifications(provider, fromSwapCurrencyStatus.status, toSwapCurrencyStatus.status) updatePermissionNotificationState(state) } is SwapState.EmptyAmountState -> { - setupEmptyAmountUiState(state, fromToken) + setupEmptyAmountUiState(state, fromSwapCurrencyStatus) lastPermissionNotificationTokens = null } is SwapState.SwapError -> { @@ -765,26 +759,20 @@ internal class SwapModel @Inject constructor( } } - private fun setupQuotesLoadedUiState( - provider: SwapProvider, - state: SwapState.QuotesLoadedState, - fromToken: CryptoCurrencyStatus, - ) { - fillLoadedDataState(state, state.permissionState, state.swapDataModel) + private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { + fillLoadedDataState(state.permissionState, state.swapDataModel) val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId uiState = stateBuilder.createQuotesLoadedState( uiStateHolder = uiState, quoteModel = state, - fromToken = fromToken.currency, feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, swapProvider = provider, bestRatedProviderId = bestRatedProviderId, isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, - isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - hideFee = tangemPayInput?.isWithdrawal == true, + hideFee = isTangemPayWithdrawal(), ) } @@ -796,7 +784,7 @@ internal class SwapModel @Inject constructor( if (uiState.notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }) { analyticsEventHandler.send( SwapEvents.NoticeNotEnoughFee( - token = initialCurrencyFrom.symbol, + token = fromToken.currency.symbol, blockchain = fromToken.currency.network.name, ), ) @@ -826,10 +814,10 @@ internal class SwapModel @Inject constructor( } private fun updatePermissionNotificationState(state: SwapState.QuotesLoadedState) { - val fromTokenId = state.fromTokenInfo.cryptoCurrencyStatus - .currency.id.value - val toTokenId = state.toTokenInfo.cryptoCurrencyStatus - .currency.id.value + val fromCryptoCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus + val toCryptoCurrencyStatus = state.toTokenInfo.swapCurrencyStatus + val fromTokenId = fromCryptoCurrencyStatus.currency.id.value + val toTokenId = toCryptoCurrencyStatus.currency.id.value val currentTokenPair = Pair(fromTokenId, toTokenId) when { @@ -843,15 +831,14 @@ internal class SwapModel @Inject constructor( } } - private fun setupEmptyAmountUiState(state: SwapState.EmptyAmountState, fromToken: CryptoCurrencyStatus) { - val toTokenStatus = dataState.toCryptoCurrency + private fun setupEmptyAmountUiState( + state: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus, + ) { uiState = stateBuilder.createQuotesEmptyAmountState( uiStateHolder = uiState, emptyAmountState = state, - fromTokenStatus = fromToken, - toTokenStatus = toTokenStatus, - isReverseSwapPossible = isReverseSwapPossible(), - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, ) } @@ -861,24 +848,25 @@ internal class SwapModel @Inject constructor( uiStateHolder = uiState, swapProvider = provider, fromToken = state.fromTokenInfo, - toToken = dataState.toCryptoCurrency, + toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, expressDataError = state.error, includeFeeInAmount = state.includeFeeInAmount, - isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - toAccount = dataState.toAccount, ) sendErrorAnalyticsEvent(state.error, provider) } private fun sendErrorAnalyticsEvent(error: ExpressDataError, provider: SwapProvider) { - val receiveToken = dataState.toCryptoCurrency?.currency?.let { currency -> - "${currency.network.backendId}:${currency.symbol}" + val fromCryptoCurrency = dataState.fromSwapCurrencyStatus?.currency?.let { currency -> + "${currency.network.rawId}:${currency.symbol}" + } + val toCryptoCurrency = dataState.toSwapCurrencyStatus?.currency?.let { currency -> + "${currency.network.rawId}:${currency.symbol}" } analyticsErrorEventHandler.sendErrorEvent( SwapEvents.NoticeProviderError( - sendToken = "${initialCurrencyFrom.network.backendId}:${initialCurrencyFrom.symbol}", - receiveToken = receiveToken.orEmpty(), + sendToken = fromCryptoCurrency.orEmpty(), + receiveToken = toCryptoCurrency.orEmpty(), provider = provider, errorCode = error.code, errorMessage = error.message, @@ -934,38 +922,16 @@ internal class SwapModel @Inject constructor( } } - private fun fillLoadedDataState( - state: SwapState.QuotesLoadedState, - permissionState: PermissionDataState, - swapDataModel: SwapDataModel?, - ) { - dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { - dataState.copy(approveDataModel = permissionState.requestApproveData) + private fun fillLoadedDataState(permissionState: PermissionDataState, swapDataModel: SwapDataModel?) { + dataState = if (permissionState is PermissionDataState.PermissionRequired) { + dataState.copy() } else { dataState.copy( swapDataModel = swapDataModel, - selectedFee = updateOrSelectFee(state), ) } } - private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? { - val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL - return when (val txFee = state.txFee) { - TxFeeState.Empty -> null - is TxFeeState.MultipleFeeState -> { - if (selectedFeeType == FeeType.NORMAL) { - txFee.normalFee - } else { - txFee.priorityFee - } - } - is TxFeeState.SingleFeeState -> { - txFee.fee - } - } - } - @Suppress("LongMethod") private fun onSwapClick() { singleTaskScheduler.cancelTask() @@ -976,10 +942,12 @@ internal class SwapModel @Inject constructor( TangemLogger.e("Last loaded quotes state is null") return } - val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fee = getSelectedFee() + val isTangemPayWithdrawal = isTangemPayWithdrawal() - if (fee == null && tangemPayInput?.isWithdrawal != true) { + if (fee == null && !isTangemPayWithdrawal) { TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) modelScope.launch { @@ -991,33 +959,32 @@ internal class SwapModel @Inject constructor( modelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { swapInteractor.onSwap( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, swapProvider = provider, swapData = dataState.swapDataModel, - currencyToSend = fromCurrency, - currencyToGet = requireNotNull(dataState.toCryptoCurrency), amountToSwap = requireNotNull(dataState.amount), - fromAccount = dataState.fromAccount, - toAccount = dataState.toAccount, includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = fee, expressOperationType = ExpressOperationType.SWAP, - isTangemPayWithdrawal = tangemPayInput?.isWithdrawal == true, + isTangemPayWithdrawal = isTangemPayWithdrawal, ) }.onSuccess { swapTransactionState -> when (swapTransactionState) { is SwapTransactionState.TxSent -> { + TangemLogger.i("onSwapClick: onSuccess: txHash: $swapTransactionState", shouldSanitize = false) if (fee == null) { TangemLogger.e("onSwapClick: onSuccess: fee is null after swap") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) return@onSuccess } sendSuccessSwapEvent( - fromCurrency.currency, + fromSwapCurrencyStatus.currency, (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, ) val url = getExplorerTransactionUrlUseCase( txHash = swapTransactionState.txHash, - currency = fromCurrency.currency, + currency = fromSwapCurrencyStatus.currency, ).getOrElse { TangemLogger.i("tx hash explore not supported") "" @@ -1034,7 +1001,7 @@ internal class SwapModel @Inject constructor( urlOpener.openUrl(url) } analyticsEventHandler.send( - event = SwapEvents.ButtonExplore(initialCurrencyFrom.symbol), + event = SwapEvents.ButtonExplore(fromSwapCurrencyStatus.currency.symbol), ) }, onStatusClick = { @@ -1042,24 +1009,31 @@ internal class SwapModel @Inject constructor( if (!txExternalUrl.isNullOrBlank()) { urlOpener.openUrl(txExternalUrl) analyticsEventHandler.send( - event = SwapEvents.ButtonStatus(initialCurrencyFrom.symbol), + event = SwapEvents.ButtonStatus(fromSwapCurrencyStatus.currency.symbol), ) } }, ) sendSuccessEvent() - swapRouter.openScreen(SwapNavScreen.Success) + router.replaceAll(SwapRoute.Success) } SwapTransactionState.DemoMode -> { showDemoModeAlert() } is SwapTransactionState.Error -> { + TangemLogger.e( + messageString = "onSwapClick: swap transaction error: $swapTransactionState", + shouldSanitize = false, + ) startLoadingQuotesFromLastState() showTransactionErrorAlert(swapTransactionState) } is SwapTransactionState.TangemPayWithdrawalData -> { - processTangemPayWithdrawal(swapTransactionState = swapTransactionState) + processTangemPayWithdrawal( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + swapTransactionState = swapTransactionState, + ) } } }.onFailure { error -> @@ -1070,9 +1044,12 @@ internal class SwapModel @Inject constructor( } } - private suspend fun processTangemPayWithdrawal(swapTransactionState: SwapTransactionState.TangemPayWithdrawalData) { + private suspend fun processTangemPayWithdrawal( + fromSwapCurrencyStatus: SwapCurrencyStatus, + swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, + ) { tangemPayWithdrawUseCase( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, cryptoAmount = swapTransactionState.cryptoAmount, cryptoCurrencyId = swapTransactionState.cryptoCurrencyId, receiverCexAddress = swapTransactionState.cexAddress, @@ -1090,10 +1067,8 @@ internal class SwapModel @Inject constructor( WithdrawalResult.Success -> { val txUrl = swapTransactionState.storeData.txExternalUrl swapInteractor.storeSwapTransaction( - currencyToSend = swapTransactionState.storeData.currencyToSend, - currencyToGet = swapTransactionState.storeData.currencyToGet, - fromAccount = swapTransactionState.storeData.fromAccount, - toAccount = swapTransactionState.storeData.toAccount, + fromSwapCurrencyStatus = swapTransactionState.storeData.fromSwapCurrencyStatus, + toSwapCurrencyStatus = swapTransactionState.storeData.toSwapCurrencyStatus, amount = swapTransactionState.storeData.amount, swapProvider = swapTransactionState.storeData.swapProvider, swapDataModel = swapTransactionState.storeData.swapDataModel, @@ -1109,7 +1084,7 @@ internal class SwapModel @Inject constructor( txUrl = txUrl.orEmpty(), onExploreClick = { if (txUrl != null) urlOpener.openUrl(txUrl) }, ) - swapRouter.openScreen(SwapNavScreen.Success) + router.replaceAll(SwapRoute.Success) } } } @@ -1118,19 +1093,19 @@ internal class SwapModel @Inject constructor( private suspend fun sendSuccessEvent() { val provider = dataState.selectedProvider ?: return val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL - val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return - val toCurrency = dataState.toCryptoCurrency?.currency ?: return - val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value - val toDerivationIndex = dataState.toAccount?.derivationIndex?.value + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return + val fromDerivationIndex = fromSwapCurrencyStatus.account.derivationIndex?.value + val toDerivationIndex = toSwapCurrencyStatus.account.derivationIndex?.value analyticsEventHandler.send( SwapEvents.SwapInProgressScreen( provider = provider, commission = fee, - sendBlockchain = fromCurrency.network.name, - receiveBlockchain = toCurrency.network.name, - sendToken = fromCurrency.symbol, - receiveToken = toCurrency.symbol, + sendBlockchain = fromSwapCurrencyStatus.currency.network.name, + receiveBlockchain = toSwapCurrencyStatus.currency.network.name, + sendToken = fromSwapCurrencyStatus.currency.symbol, + receiveToken = toSwapCurrencyStatus.currency.symbol, feeToken = getFeeToken().symbol, fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, @@ -1139,307 +1114,50 @@ internal class SwapModel @Inject constructor( ) } - @Suppress("LongMethod") - private fun givePermissionsToSwap() { - modelScope.launch(dispatchers.main) { - runSuspendCatching { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) { - "dataState.fromCryptoCurrency might not be null" - } - val fromToken = fromCryptoCurrency.currency + private fun subscribeToCoinBalanceUpdates(swapCurrencyStatus: SwapCurrencyStatus, isFromCurrency: Boolean) { + val swapCurrency = swapCurrencyStatus.currency - val approveDataModel = requireNotNull(dataState.approveDataModel) { - "dataState.approveDataModel.spenderAddress shouldn't be null" - } - val approveType = - requireNotNull(uiState.permissionState.getApproveTypeOrNull()?.toDomainApproveType()) { - "uiState.permissionState should not be null" - } - val feeForPermission = when (val fee = approveDataModel.fee) { - TxFeeState.Empty -> { - showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) - TangemLogger.e("Fee should not be Empty") - return@launch - } - is TxFeeState.MultipleFeeState -> fee.priorityFee - is TxFeeState.SingleFeeState -> fee.fee - } - runCatching(dispatchers.io) { - swapInteractor.givePermissionToSwap( - networkId = fromToken.network.backendId, - permissionOptions = PermissionOptions( - approveData = approveDataModel, - forTokenContractAddress = (fromToken as? CryptoCurrency.Token)?.contractAddress.orEmpty(), - fromTokenStatus = fromCryptoCurrency, - approveType = approveType, - txFee = feeForPermission, - spenderAddress = requireNotNull(dataState.approveDataModel).spenderAddress, - ), - ) - }.onSuccess { swapTransactionState -> - when (swapTransactionState) { - is SwapTransactionState.TxSent -> { - // TODO [REDACTED_TASK_KEY] gasless analytics - sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType) - updateWalletBalance() - uiState = stateBuilder.loadingPermissionState(uiState) - uiState = stateBuilder.dismissBottomSheet(uiState) - startLoadingQuotesFromLastState(isSilent = true) - } - is SwapTransactionState.Error -> { - showTransactionErrorAlert(swapTransactionState) - } - SwapTransactionState.DemoMode -> { - showDemoModeAlert() - } - is SwapTransactionState.TangemPayWithdrawalData -> { - processTangemPayWithdrawal(swapTransactionState = swapTransactionState) - } - } - }.onFailure { showAlert() } - }.onFailure { error -> - TangemLogger.e(error.message.orEmpty()) - showAlert() - } - } - } + when (swapCurrencyStatus.account) { + is Account.CryptoPortfolio -> getAccountCurrencyStatusUseCase( + userWalletId = swapCurrencyStatus.userWalletId, + currency = swapCurrency, + ).map { (_, status) -> status } + is Account.Payment -> getPaymentAccountCryptoCurrencyStatusUseCase( + userWalletId = swapCurrencyStatus.userWalletId, + cryptoCurrency = swapCurrency, + ).map { (_, status) -> status } + }.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes + .onEach { currencyStatus -> - private fun onSearchEntered(searchQuery: String) { - val tokenDataState = dataState.tokensDataState ?: return - val group = if (isOrderReversed.value) { - tokenDataState.fromGroup - } else { - tokenDataState.toGroup - } - - val available = group.available.filter { swapAvailability -> - swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || - swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) - } - val unavailable = group.unavailable.filter { swapAvailability -> - swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || - swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) - } - val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability -> - val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency -> - val currency = accountSwapCurrency.cryptoCurrencyStatus.currency - currency.name.contains(searchQuery, ignoreCase = true) || - currency.symbol.contains(searchQuery, ignoreCase = true) - } - - if (filteredCurrencies.isEmpty()) { - return@mapNotNull null - } - - accountSwapAvailability.copy( - currencyList = filteredCurrencies, - ) - } - - val filteredTokenDataState = if (isOrderReversed.value) { - tokenDataState.copy( - fromGroup = tokenDataState.fromGroup.copy( - available = available, - unavailable = unavailable, - accountCurrencyList = accountCurrencyList, - isAfterSearch = true, - ), - ) - } else { - tokenDataState.copy( - toGroup = tokenDataState.toGroup.copy( - available = available, - unavailable = unavailable, - accountCurrencyList = accountCurrencyList, - isAfterSearch = true, - ), - ) - } - updateTokensState(filteredTokenDataState) - } - - @Suppress("LongMethod") - private fun onTokenSelect(id: String, isSearched: Boolean) { - val tokens = dataState.tokensDataState ?: return - val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id) - - foundToken?.currency?.symbol?.let { symbol -> - analyticsEventHandler.send( - SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol), - ) - - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = symbol, - source = ScreensSources.Portfolio, - isSearched = isSearched, - ), - ) - } - - if (foundToken != null) { - val fromToken: CryptoCurrencyStatus - val fromAccount: Account.CryptoPortfolio? - val toToken: CryptoCurrencyStatus - val toAccount: Account.CryptoPortfolio? - if (isOrderReversed.value) { - fromToken = foundToken - fromAccount = foundAccount - toToken = initialFromStatus - toAccount = fromAccountCurrencyStatus?.account - - val newToken = fromToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = true, - ) - } else { - fromTokenBalanceJobHolder.cancel() - } - } else { - fromToken = initialFromStatus - fromAccount = fromAccountCurrencyStatus?.account - toToken = foundToken - toAccount = foundAccount - - val newToken = toToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = false, - ) - } else { - toTokenBalanceJobHolder.cancel() - } - } - - if (dataState.fromCryptoCurrency != null && dataState.tokensDataState != null) { - isAmountChangedByUser = true - } - - dataState = dataState.copy( - fromCryptoCurrency = fromToken, - fromAccount = fromAccount, - toCryptoCurrency = toToken, - toAccount = toAccount, - selectedProvider = null, - ) - swapRouter.openScreen(SwapNavScreen.Main) - if (handleSwapNotSupported( - state = tokens, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return - } - modelScope.launch { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = fromToken.currency, - initialCurrencyTo = toToken.currency, - fromNetworkInfo = fromToken.currency.getNetworkInfo(), - ) - } - updateFeePaidCryptoCurrencyFor(fromToken) - startLoadingQuotes( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), - ) - } - updateTokensState(tokens) - } - } - - private fun getSelectedTokenAndAccount( - tokens: TokensDataStateExpress, - id: String, - ): Pair { - val accountCryptoCurrencyStatus = if (isOrderReversed.value) { - tokens.fromGroup - } else { - tokens.toGroup - }.accountCurrencyList.firstNotNullOfOrNull { accountSwapAvailability -> - accountSwapAvailability.currencyList.firstOrNull { accountSwapCurrency -> - accountSwapCurrency.cryptoCurrencyStatus.currency.id.value == id - } - } - return accountCryptoCurrencyStatus?.cryptoCurrencyStatus to accountCryptoCurrencyStatus?.account - } - - @Suppress("LongMethod", "CyclomaticComplexMethod") - private fun subscribeToCoinBalanceUpdates( - userWalletId: UserWalletId, - coin: CryptoCurrency.Coin, - isFromCurrency: Boolean, - ) { - TangemLogger.d("Subscribe to ${coin.id} balance updates") - - getAccountCurrencyStatusUseCase( - userWalletId = userWalletId, - currency = coin, - ).distinctUntilChanged { old, new -> old.status.value.amount == new.status.value.amount } // Check only balance changes - .onEach { (account, currencyStatus) -> - TangemLogger.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}") - - if (isFromCurrency) { - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = currencyStatus, + when { + isFromCurrency && currencyStatus.currency.id == swapCurrency.id -> { + dataState = dataState.copy( + fromSwapCurrencyStatus = swapCurrencyStatus.copy(status = currencyStatus), ) - .onLeft { - TangemLogger.e( - "Coin balance: Unable to get fee paid crypto currency status for " + - "${currencyStatus.currency.id}", + } + !isFromCurrency && currencyStatus.currency.id == swapCurrency.id -> { + dataState = dataState.copy( + toSwapCurrencyStatus = swapCurrencyStatus.copy(status = currencyStatus), + ) + } + else -> Unit + } + + uiState = stateBuilder.updateCurrencyBalanceStatus( + uiState = uiState, + fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus, + toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, ) - } - .onRight { status -> - if (status == null) { - TangemLogger.e( - "Coin balance: Fee paid crypto currency status is null " + - "for ${currencyStatus.currency.id}", - ) - } - } - .getOrNull() - ?: currencyStatus, - ) - } - - uiState = when { - isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> { - dataState = dataState.copy( - fromCryptoCurrency = currencyStatus, - fromAccount = account, - ) - stateBuilder.updateSendCurrencyBalance(uiState, currencyStatus) - } - !isFromCurrency && currencyStatus.currency.id == dataState.toCryptoCurrency?.currency?.id -> { - dataState = dataState.copy( - toCryptoCurrency = currencyStatus, - toAccount = account, - ) - stateBuilder.updateReceiveCurrencyBalance(uiState, currencyStatus) - } - else -> { - uiState - } - } + }, + ), + ), + ) startLoadingQuotesFromLastState(isSilent = true) } .flowOn(dispatchers.main) @@ -1447,74 +1165,20 @@ internal class SwapModel @Inject constructor( .saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder) } - private fun onChangeCardsClicked() { - modelScope.launch { - val newFromToken = dataState.toCryptoCurrency - val newFromAccount = dataState.toAccount - val newToToken = dataState.fromCryptoCurrency - val newToAccount = dataState.fromAccount - - if (newFromToken != null && newToToken != null) { - isAmountChangedByUser = true - - dataState = dataState.copy( - fromCryptoCurrency = newFromToken, - fromAccount = newFromAccount, - toCryptoCurrency = newToToken, - toAccount = newToAccount, - ) - isOrderReversed.value = !isOrderReversed.value - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - updateFeePaidCryptoCurrencyFor(newFromToken) - dataState.tokensDataState?.let { tokensDataState -> - updateTokensState(tokensDataState) - } - - val minTxAmount = getMinimumTransactionAmountSyncUseCase( - userWalletId, - newFromToken, - ).getOrNull() - val decimals = newFromToken.currency.decimals - lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value) - lastReducedBalanceBy.value = BigDecimal.ZERO - uiState = stateBuilder.updateSwapAmount( - uiState = uiState, - amountFormatted = inputNumberFormatter.formatWithThousands(lastAmount.value, decimals), - amountRaw = lastAmount.value, - fromToken = newFromToken.currency, - minTxAmount = minTxAmount, - fromAccount = dataState.fromAccount, - ) - startLoadingQuotes( - fromToken = newFromToken, - fromAccount = newFromAccount, - toToken = newToToken, - toAccount = newToAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(newFromToken, newToToken), - ) - } - } - } - private fun onAmountChanged( value: String, forceQuotesUpdate: Boolean = false, reduceBalanceBy: BigDecimal = BigDecimal.ZERO, ) { modelScope.launch { - val fromToken = dataState.fromCryptoCurrency - val toToken = dataState.toCryptoCurrency - if (fromToken != null) { - val decimals = fromToken.currency.decimals + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + if (fromSwapCurrencyStatus != null) { + val decimals = fromSwapCurrencyStatus.currency.decimals val cutValue = cutAmountWithDecimals(decimals, value) val minTxAmount = getMinimumTransactionAmountSyncUseCase( - userWalletId, - fromToken, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).getOrNull() lastAmount.value = cutValue lastReducedBalanceBy.value = reduceBalanceBy @@ -1522,25 +1186,22 @@ internal class SwapModel @Inject constructor( uiState = uiState, amountFormatted = inputNumberFormatter.formatWithThousands(cutValue, decimals), amountRaw = lastAmount.value, - fromToken = fromToken.currency, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, minTxAmount = minTxAmount, - fromAccount = dataState.fromAccount, ) - if (toToken != null) { - if (toToken.value.amount != null) { + if (toSwapCurrencyStatus != null) { + if (toSwapCurrencyStatus.status.value.amount != null) { isAmountChangedByUser = true } amountDebouncer.debounce(modelScope, DEBOUNCE_AMOUNT_DELAY, forceUpdate = forceQuotesUpdate) { startLoadingQuotes( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), + toProvidersList = dataState.selectedPairProviders, ) } } @@ -1549,8 +1210,8 @@ internal class SwapModel @Inject constructor( } private fun onMaxAmountClicked() { - dataState.fromCryptoCurrency?.let { fromCurrency -> - val balance = swapInteractor.getTokenBalance(fromCurrency) + dataState.fromSwapCurrencyStatus?.let { fromCurrency -> + val balance = swapInteractor.getTokenBalance(fromCurrency.status) onAmountChanged(balance.formatToUIRepresentation()) } } @@ -1602,9 +1263,11 @@ internal class SwapModel @Inject constructor( } private fun onTangemPaySupportClick(txId: String) { + val fromUserWalletId = dataState.fromSwapCurrencyStatus?.userWalletId ?: return + modelScope.launch { - val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch - val customerId = getTangemPayCustomerIdUseCase(userWallet.walletId).getOrNull().orEmpty() + val metaInfo = getWalletMetaInfoUseCase(fromUserWalletId).getOrNull() ?: return@launch + val customerId = getTangemPayCustomerIdUseCase(fromUserWalletId).getOrNull().orEmpty() val email = FeedbackEmailType.Visa.Withdrawal( walletMetaInfo = metaInfo, customerId = customerId, @@ -1670,8 +1333,8 @@ internal class SwapModel @Inject constructor( onAmountChanged = { onAmountChanged(it) }, onSwapClick = { onSwapClick() - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol + val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol if (sendTokenSymbol != null && receiveTokenSymbol != null) { analyticsEventHandler.send( SwapEvents.ButtonSwapClicked( @@ -1681,10 +1344,6 @@ internal class SwapModel @Inject constructor( ) } }, - onGivePermissionClick = { - givePermissionsToSwap() - sendPermissionApproveClickedEvent() - }, onChangeCardsClicked = { onChangeCardsClicked() analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked()) @@ -1694,9 +1353,8 @@ internal class SwapModel @Inject constructor( if (bottomSheet != null && bottomSheet.isShown) { uiState = stateBuilder.dismissBottomSheet(uiState) } else { - swapRouter.back() + router.pop() } - onSearchEntered("") }, onMaxAmountSelected = ::onMaxAmountClicked, onReduceToAmount = ::onReduceAmountClicked, @@ -1704,30 +1362,23 @@ internal class SwapModel @Inject constructor( openPermissionBottomSheet = { singleTaskScheduler.cancelTask() sendGivePermissionClickedEvent() - if (shouldUseGaslessApproval) { - approvalSlotNavigation.activate(Unit) - } else { - uiState = stateBuilder.showPermissionBottomSheet(uiState) { - startLoadingQuotesFromLastState(isSilent = true) - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked()) - uiState = stateBuilder.dismissBottomSheet(uiState) - } - } + approvalSlotNavigation.activate(Unit) }, onAmountSelected = { onAmountSelected(it) }, - onChangeApproveType = { approveType -> - uiState = stateBuilder.updateApproveType(uiState, approveType) - }, onClickFee = { val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val txFeeState = dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions - uiState = stateBuilder.showSelectFeeBottomSheet( - uiState = uiState, - selectedFee = selectedFee, - txFeeState = txFeeState, - ) { - uiState = stateBuilder.dismissBottomSheet(uiState) + modelScope.launch { + val readMoreUrl = TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee) + uiState = stateBuilder.showSelectFeeBottomSheet( + uiState = uiState, + selectedFee = selectedFee, + txFeeState = txFeeState, + readMoreUrl = readMoreUrl, + ) { + uiState = stateBuilder.dismissBottomSheet(uiState) + } } }, onSelectFeeType = { txFee -> @@ -1752,9 +1403,10 @@ internal class SwapModel @Inject constructor( onProviderSelect = { providerId -> val provider = findAndSelectProvider(providerId) val swapState = dataState.lastLoadedSwapStates[provider] - val fromToken = dataState.fromCryptoCurrency - val toToken = dataState.toCryptoCurrency - if (provider != null && swapState != null && fromToken != null) { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null + if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch { feeSelectorRepository.state.value = FeeSelectorUM.Loading feeSelectorReloadTrigger.triggerUpdate() @@ -1764,40 +1416,78 @@ internal class SwapModel @Inject constructor( setupLoadedState( provider = provider, state = swapState, - fromToken = fromToken, - toToken = toToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, ) } }, - onBuyClick = { currency -> - swapRouter.openTokenDetails( - userWalletId = userWalletId, - currency = currency, + onBuyClick = { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions + val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency ?: return@UiActions + val route = AppRoute.CurrencyDetails( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = feePaidCryptoCurrency.currency, ) + + appRouter.push(route) }, onRetryClick = { startLoadingQuotesFromLastState() }, onReceiveCardWarningClick = { val selectedProvider = dataState.selectedProvider ?: return@UiActions - val currencySymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return@UiActions + val currencySymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return@UiActions val isPriceImpact = uiState.priceImpact.type != PriceImpact.Type.NONE showSwapInfoAlert(isPriceImpact, currencySymbol, selectedProvider) }, onLinkClick = urlOpener::openUrl, - onSelectTokenClick = { - swapRouter.openScreen(SwapNavScreen.SelectToken) - sendSelectTokenScreenOpenedEvent() + onSelectTokenClick = { direction -> + singleTaskScheduler.cancelTask() // Need to stop auto quotes fetching + router.push( + SwapRoute.SelectToken(isFromDirection = direction == TokenSelectionDirection.FROM), + ) }, onSuccess = { - swapRouter.openScreen(SwapNavScreen.Success) - }, - onOpenLearnMoreAboutApproveClick = { - urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP) + router.replaceAll(SwapRoute.Success) }, ) } + private fun selectWalletInSelector( + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + ) { + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus == null) { + chooseFromTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + } else if (fromSwapCurrencyStatus == null && toSwapCurrencyStatus != null) { + chooseFromTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + } else if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + chooseFromTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + } + } + + private fun filterTokensFromSelector() { + val tokenFilter = { accountStatus: AccountStatus, currencyStatus: CryptoCurrencyStatus -> + if (currencyStatus.currency.isCustom) { + false + } else { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + + (fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || + fromSwapCurrencyStatus.currency.id != currencyStatus.currency.id) && + (toSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || + toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) + } + } + + chooseFromTokenBridge.tokenFilter.value = tokenFilter + chooseToTokenBridge.tokenFilter.value = tokenFilter + } + private fun sendSuccessSwapEvent(fromToken: CryptoCurrency, feeType: FeeType) { val event = AnalyticsParam.TxSentFrom.Swap( blockchain = fromToken.network.name, @@ -1814,7 +1504,7 @@ internal class SwapModel @Inject constructor( } private fun getFeeToken(): CryptoCurrency { - val fromToken = requireNotNull(dataState.fromCryptoCurrency) { + val fromToken = requireNotNull(dataState.fromSwapCurrencyStatus) { "fromCryptoCurrency should not be null" } return when (val fee = getSelectedFee()) { @@ -1825,23 +1515,6 @@ internal class SwapModel @Inject constructor( } } - private fun sendApproveSuccessEvent(fromToken: CryptoCurrency, feeType: FeeType, approveType: SwapApproveType) { - val feeToken = getFeeToken().symbol - val event = AnalyticsParam.TxSentFrom.Approve( - blockchain = fromToken.network.name, - token = fromToken.symbol, - feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()), - permissionType = approveType.getNameForAnalytics(), - feeToken = feeToken, - ) - analyticsEventHandler.send( - Basic.TransactionSent( - sentFrom = event, - memoType = Basic.TransactionSent.MemoType.Null, - ), - ) - } - private fun findAndSelectProvider(providerId: String): SwapProvider? { val selectedProvider = dataState.lastLoadedSwapStates.keys.firstOrNull { it.providerId == providerId } if (selectedProvider != null) { @@ -1861,7 +1534,7 @@ internal class SwapModel @Inject constructor( if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) { fromAmountFiat.divide( toAmountFiat, - toTokenInfo.cryptoCurrencyStatus.currency.decimals, + toTokenInfo.swapCurrencyStatus.currency.decimals, RoundingMode.HALF_UP, ) } else { @@ -1900,91 +1573,34 @@ internal class SwapModel @Inject constructor( ) } - private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List { - val groupToFind = if (isOrderReversed.value) { - dataState.tokensDataState?.fromGroup - } else { - dataState.tokensDataState?.toGroup - } ?: return emptyList() - - val idToFind = if (isOrderReversed.value) { - fromToken.currency.id.value - } else { - toToken.currency.id.value - } - - return groupToFind.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.find { accountSwapCurrency -> - idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value && - accountSwapCurrency.isAvailable - } - }?.providers - ?.filterForTangemPayWithdrawal() - .orEmpty() - } - - /** - * @return true if swap is not supported and UI was updated to show error state - */ private fun handleSwapNotSupported( - state: TokensDataStateExpress, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, - ): Boolean { - val selectedCurrency = if (isOrderReversed.value) fromToken else toToken - if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false - + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ) { + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency analyticsEventHandler.send( SwapEvents.NoticeUnavailableToSwapPair( - sendToken = fromToken.currency.symbol, - receiveToken = toToken.currency.symbol, - sendBlockchain = fromToken.currency.network.name, - receiveBlockchain = toToken.currency.network.name, + sendToken = fromCurrency.symbol, + receiveToken = toCurrency.symbol, + sendBlockchain = fromCurrency.network.name, + receiveBlockchain = toCurrency.network.name, ), ) // Cancel periodic quote task if selected token is not supported singleTaskScheduler.cancelTask() - // Reset data state - dataState = SwapProcessDataState( - tokensDataState = dataState.tokensDataState, - ) + lastReducedBalanceBy.value = BigDecimal.ZERO lastAmount.value = INITIAL_AMOUNT uiState = stateBuilder.createSwapNotSupportedState( uiStateHolder = uiState, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - mainTokenId = initialCurrencyFrom.id.value, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, ) - return true } - private fun isTokenAvailableForSwap( - state: TokensDataStateExpress, - selectedCurrency: CryptoCurrencyStatus, - isReverseFromTo: Boolean, - ): Boolean { - val group = if (isReverseFromTo) state.fromGroup else state.toGroup - val idToFind = selectedCurrency.currency.id.value - - return group.accountCurrencyList.any { (_, currencyList) -> - currencyList.any { accountSwapCurrency -> - idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value && - accountSwapCurrency.isAvailable - } - } - } - - private fun List.filterForTangemPayWithdrawal(): List { - return if (tangemPayInput?.isWithdrawal == true) { - filter { it.type == ExchangeProviderType.CEX } - } else { - this - } + private fun isTangemPayWithdrawal(): Boolean { + return tangemPayInput?.isWithdrawal == true || dataState.fromSwapCurrencyStatus?.account is Account.Payment } private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { @@ -1998,29 +1614,9 @@ internal class SwapModel @Inject constructor( } } - private fun isReverseSwapPossible(): Boolean { - if (tangemPayInput != null) return false - val from = dataState.fromCryptoCurrency ?: return false - val to = dataState.toCryptoCurrency ?: return false - - val currenciesGroup = if (isOrderReversed.value) { - dataState.tokensDataState?.toGroup - } else { - dataState.tokensDataState?.fromGroup - } ?: return false - - val chosen = if (isOrderReversed.value) from else to - - return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability -> - accountSwapAvailability.currencyList.map { accountSwapCurrency -> - accountSwapCurrency.cryptoCurrencyStatus - } - }.map { currencyStatus -> currencyStatus.currency }.contains(chosen.currency) - } - private fun sendNoticePermissionNeededEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return + val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol ?: return + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return val provider = dataState.selectedProvider ?: return analyticsEventHandler.send( SwapEvents.NoticePermissionNeeded( @@ -2032,8 +1628,8 @@ internal class SwapModel @Inject constructor( } private fun sendGivePermissionClickedEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return + val sendTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return val provider = dataState.selectedProvider ?: return analyticsEventHandler.send( SwapEvents.ButtonGivePermissionClicked( @@ -2044,27 +1640,14 @@ internal class SwapModel @Inject constructor( ) } - private fun sendPermissionApproveClickedEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return - val approveType = uiState.permissionState.getApproveTypeOrNull() ?: return - val provider = dataState.selectedProvider ?: return - - analyticsEventHandler.send( - SwapEvents.ButtonPermissionApproveClicked( - sendToken = sendTokenSymbol, - receiveToken = receiveTokenSymbol, - approveType = approveType, - provider = provider, - ), - ) - } - private fun updateWalletBalance() { - dataState.fromCryptoCurrency?.currency?.network?.let { network -> + dataState.fromSwapCurrencyStatus?.let { fromSwapCurrencyStatus -> modelScope.launch { withContext(NonCancellable) { - updateForBalance(userWalletId, network) + updateForBalance( + fromSwapCurrencyStatus.userWalletId, + fromSwapCurrencyStatus.currency.network, + ) } } } @@ -2078,13 +1661,6 @@ internal class SwapModel @Inject constructor( ) } - private fun ApproveType.toDomainApproveType(): SwapApproveType { - return when (this) { - ApproveType.LIMITED -> SwapApproveType.LIMITED - ApproveType.UNLIMITED -> SwapApproveType.UNLIMITED - } - } - private fun triggerPromoProviderEvent(recommendedProvider: SwapProvider?, bestQuotesProvider: SwapProvider?) { // for now send event only for changelly if (recommendedProvider == null || @@ -2111,16 +1687,17 @@ internal class SwapModel @Inject constructor( private fun onFailedTxEmailClick(errorMessage: String) { modelScope.launch { val transaction = dataState.swapDataModel?.transaction - val fromCurrencyStatus = dataState.fromCryptoCurrency ?: initialFromStatus - val network = fromCurrencyStatus.currency.network + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.cryptoCurrency + val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId + val network = fromCurrency?.network saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = network.rawId, - derivationPath = network.derivationPath.value, + networkId = network?.id, destinationAddress = transaction?.txTo.orEmpty(), - tokenSymbol = fromCurrencyStatus.currency.symbol, + tokenSymbol = fromCurrency?.symbol.orEmpty(), amount = dataState.amount.orEmpty(), fee = when (val fee = getSelectedFee()) { is TxFee.FeeComponent -> fee.fee.amount.value?.toString() @@ -2130,7 +1707,7 @@ internal class SwapModel @Inject constructor( ), ) - val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId) + val metaInfo = getWalletMetaInfoUseCase(fromWalletId) .getOrElse { error("CardInfo must be not null") } val email = FeedbackEmailType.SwapProblem( @@ -2144,33 +1721,15 @@ internal class SwapModel @Inject constructor( } } - private fun CryptoCurrency.getNetworkInfo(): NetworkInfo { - return NetworkInfo( - name = this.network.name, - blockchainId = this.network.rawId, - ) - } - - private suspend fun getFromStatus(): CryptoCurrencyStatus? { - return if (tangemPayInput != null) { - getTangemPayCurrencyStatusUseCase( - currency = initialCurrencyFrom, - cryptoAmount = tangemPayInput.cryptoAmount, - fiatAmount = tangemPayInput.fiatAmount, - depositAddress = tangemPayInput.depositAddress, - ) - } else { - singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) - .getCryptoCurrencyStatus(currency = initialCurrencyFrom) - .getOrNull() - } - } - private fun getSelectedFeeState(): TxFeeSealedState { val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content if (feeStateUM == null) { - TangemLogger.e("getSelectedFeeState: FeeSelectorUM is not Content: $feeStateUM, returning Legacy state") + TangemLogger.e( + messageString = "getSelectedFeeState: FeeSelectorUM is not Content: $feeStateUM, " + + "returning Legacy state", + shouldSanitize = false, + ) return TxFeeSealedState.Legacy( txFeeState = TxFeeState.Empty, selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, @@ -2192,7 +1751,10 @@ internal class SwapModel @Inject constructor( val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content if (feeStateUM == null) { - TangemLogger.e("getSelectedFee: FeeSelectorUM is not Content: $feeStateUM, returning null") + TangemLogger.e( + messageString = "getSelectedFee: FeeSelectorUM is not Content: $feeStateUM, returning null", + shouldSanitize = false, + ) return null } @@ -2218,8 +1780,8 @@ internal class SwapModel @Inject constructor( override suspend fun loadFeeExtended( selectedToken: CryptoCurrencyStatus?, ): Either { - val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) - val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) + val fromSwapCurrencyStatus = + dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! if (selectedProvider.type != ExchangeProviderType.CEX) { @@ -2235,10 +1797,7 @@ internal class SwapModel @Inject constructor( } return swapInteractor.loadFeeForSwapTransaction( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, provider = selectedProvider, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, @@ -2257,9 +1816,11 @@ internal class SwapModel @Inject constructor( return } + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + // If fee currency is same as from currency, we need to reload quotes to update fee info val isFeeCurrencySameAsFromCurrency = newState is FeeSelectorUM.Content && - dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id + fromSwapCurrencyStatus?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id // If fee currency is coin, we need to reload quotes to update fee related warnings (e.g. insufficient funds) val isCoinFeeSelected = newState is FeeSelectorUM.Content && @@ -2292,12 +1853,17 @@ internal class SwapModel @Inject constructor( override suspend fun loadFee(): Either { TangemLogger.e("loadFee: Start loading fee") - val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) - val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) + val fromSwapCurrencyStatus = + dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val toSwapCurrencyStatus = + dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { - TangemLogger.e("loadFee: Quotes not loaded ${dataState.lastLoadedSwapStates[selectedProvider]}") + TangemLogger.e( + messageString = "loadFee: Quotes not loaded ${dataState.lastLoadedSwapStates[selectedProvider]}", + shouldSanitize = false, + ) return Either.Left(GetFeeError.UnknownError) } @@ -2307,20 +1873,16 @@ internal class SwapModel @Inject constructor( } return swapInteractor.loadFeeForSwapTransaction( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = selectedProvider, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, - ) - .onLeft { - TangemLogger.e("loadFee: Failed to load fee with error $it") - } - .onRight { - TangemLogger.e("loadFee: Fee loaded successfully") - } + ).onLeft { + TangemLogger.e("loadFee: Failed to load fee with error $it") + }.onRight { + TangemLogger.e("loadFee: Fee loaded successfully") + } } override fun choosingInProgress(updatedState: Boolean) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 6586b11550..5406397bbd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -37,15 +37,6 @@ internal class SwapNotificationsFactory( private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { - fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList { - return persistentListOf( - SwapNotificationUM.Warning.ExpressGeneralError( - code = code, - onConfirmClick = onRefreshClick, - ), - ) - } - fun getGeneralErrorStateNotifications( message: TextReference?, onClick: () -> Unit, @@ -104,7 +95,6 @@ internal class SwapNotificationsFactory( @Suppress("LongParameterList") fun getConfirmationStateNotifications( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, selectedFeeType: FeeType, providerName: String, @@ -114,9 +104,9 @@ internal class SwapNotificationsFactory( maybeAddRentExemptionError(quoteModel) maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType) maybeAddNeedReserveToCreateAccountWarning(quoteModel) - maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName) + maybeAddPermissionNeededWarning(quoteModel, providerName) maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType) - maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee) + maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee) maybeAddTransactionInProgressWarning(quoteModel) maybeAddPriceImpactNotification(quoteModel.priceImpact) } @@ -135,7 +125,7 @@ internal class SwapNotificationsFactory( if (quoteModel.permissionState is PermissionDataState.PermissionLoading) { add(SwapNotificationUM.Error.ApprovalInProgressWarning) } else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) { - val fromCurrency = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency + val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency add( SwapNotificationUM.Error.TransactionInProgressWarning( currencySymbol = fromCurrency.network.currencySymbol, @@ -162,7 +152,7 @@ internal class SwapNotificationsFactory( feeCryptoCurrencyStatus: CryptoCurrencyStatus?, selectedFeeType: FeeType, ) { - val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus + val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount val amount = quoteModel.fromTokenInfo.tokenAmount val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { @@ -179,14 +169,14 @@ internal class SwapNotificationsFactory( } is TxFeeState.SingleFeeState -> feeState.fee } - val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.rawId) + val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId) // blockchain specific addExistentialWarningNotification( existentialDeposit = quoteModel.currencyCheck?.existentialDeposit, feeAmount = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, onReduceClick = { reduceBy, reduceByDiff, _ -> actions.onReduceByAmount( // use in swap notification amountToRequest because fee is already subtracted @@ -198,7 +188,7 @@ internal class SwapNotificationsFactory( addValidateTransactionNotifications( dustValue = quoteModel.currencyCheck?.dustValue.orZero(), validationError = quoteModel.validationResult, - cryptoCurrency = fromCurrencyStatus.currency, + cryptoCurrency = swapCurrencyStatus.currency, minAdaValue = quoteModel.minAdaValue, onReduceClick = { reduceTo, _ -> actions.onReduceToAmount(amount.copy(value = reduceTo)) @@ -209,26 +199,26 @@ internal class SwapNotificationsFactory( dustValue = quoteModel.currencyCheck?.dustValue, feeValue = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, feeCurrencyStatus = feeCryptoCurrencyStatus, ) } addReserveAmountErrorNotification( reserveAmount = quoteModel.currencyCheck?.reserveAmount, sendingAmount = amountToRequest.value, - cryptoCurrency = fromCurrencyStatus.currency, + cryptoCurrency = swapCurrencyStatus.currency, feeCryptoCurrency = feeCryptoCurrencyStatus?.currency, isAccountFunded = true, // consider the account is funded on the provider side ) addReduceAmountNotification( - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, fromAmount = quoteModel.fromTokenInfo.tokenAmount, onReduceByAmount = actions.onReduceByAmount, ) addTransactionLimitErrorNotification( currencyCheck = quoteModel.currencyCheck, sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, feeCurrencyStatus = feeCryptoCurrencyStatus, feeValue = fee?.feeValue.orZero(), onReduceClick = { reduceTo, _ -> @@ -240,11 +230,11 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddNeedReserveToCreateAccountWarning( quoteModel: SwapState.QuotesLoadedState, ) { - val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value + val status = quoteModel.toTokenInfo.swapCurrencyStatus.status.value if (status is CryptoCurrencyStatus.NoAccount) { val amount = quoteModel.toTokenInfo.tokenAmount.value val amountToCreateAccount = status.amountToCreateAccount - val currencyTo = quoteModel.toTokenInfo.cryptoCurrencyStatus.currency + val currencyTo = quoteModel.toTokenInfo.swapCurrencyStatus.currency if (amount < amountToCreateAccount) { add( SwapNotificationUM.Warning.NeedReserveToCreateAccount( @@ -258,17 +248,13 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddPermissionNeededWarning( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, providerName: String, ) { - if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough && - quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest - ) { + if (quoteModel.permissionState is PermissionDataState.PermissionRequired) { add( SwapNotificationUM.Info.PermissionNeeded( providerName = providerName, - fromTokenSymbol = fromToken.symbol, + fromTokenSymbol = quoteModel.fromTokenInfo.swapCurrencyStatus.currency.symbol, onApproveClick = actions.openPermissionBottomSheet, ), ) @@ -308,27 +294,28 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddUnableCoverFeeWarning( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus?, hideFee: Boolean, ) { if (hideFee) return + val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && - feeEnoughState.feeCurrency != fromToken + feeCryptoCurrencyStatus?.currency != fromCurrency val isNotEnoughFee = quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough - val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) && + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && quoteModel.swapProvider.type == ExchangeProviderType.CEX if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( - fromToken = fromToken, - feeCurrency = feeEnoughState.feeCurrency, - currencyName = feeEnoughState.currencyName ?: fromToken.network.name, - currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol, + fromToken = fromCurrency, + feeCurrency = feeCryptoCurrencyStatus?.currency, + currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name, + currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol, onConfirmClick = actions.onBuyClick, ), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index f352427c56..0f2e373166 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -1,10 +1,10 @@ package com.tangem.feature.swap.model -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress import com.tangem.feature.swap.domain.models.ui.TxFee @@ -12,20 +12,24 @@ import java.math.BigDecimal data class SwapProcessDataState( // Initial network id - val fromCryptoCurrency: CryptoCurrencyStatus? = null, - val toCryptoCurrency: CryptoCurrencyStatus? = null, + val fromSwapCurrencyStatus: SwapCurrencyStatus? = null, + val toSwapCurrencyStatus: SwapCurrencyStatus? = null, + val feePaidCryptoCurrency: CryptoCurrencyStatus? = null, - val fromAccount: Account.CryptoPortfolio? = null, - val toAccount: Account.CryptoPortfolio? = null, + + // swap info + val pairs: List = emptyList(), + + val selectedPairProviders: List = emptyList(), + val selectedProvider: SwapProvider? = null, + val lastLoadedSwapStates: Map = emptyMap(), + // Amount from input val amount: String? = null, val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, - val approveDataModel: RequestApproveStateData? = null, val swapDataModel: SwapDataModel? = null, val selectedFee: TxFee.Legacy? = null, val tokensDataState: TokensDataStateExpress? = null, - val selectedProvider: SwapProvider? = null, - val lastLoadedSwapStates: Map = emptyMap(), ) { fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt deleted file mode 100644 index afebcd52cf..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.feature.swap.models - -import com.tangem.core.decompose.navigation.Route -import kotlinx.serialization.Serializable - -@Serializable -internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 0afbd2da4b..f45012a0b1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -4,11 +4,10 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState @@ -18,14 +17,13 @@ import kotlinx.collections.immutable.persistentListOf internal data class SwapStateHolder( val sendCardData: SwapCardState, val receiveCardData: SwapCardState, - val blockchainId: String, // not the same as networkId, its local id in app val notifications: ImmutableList = persistentListOf(), val isInsufficientFunds: Boolean, val changeCardsButtonState: ChangeCardsButtonState, val providerState: ProviderState, val fee: FeeItemState = FeeItemState.Empty, - val permissionState: GiveTxPermissionState = GiveTxPermissionState.Empty, + val permissionUM: SwapPermissionUM = SwapPermissionUM.Empty, val priceImpact: PriceImpact, val successState: SwapSuccessStateHolder? = null, @@ -37,34 +35,35 @@ internal data class SwapStateHolder( val onRefresh: () -> Unit, val onBackClicked: () -> Unit, val onChangeCardsClicked: () -> Unit, - val onSelectTokenClick: (() -> Unit), + val onSelectTokenClick: ((TokenSelectionDirection) -> Unit), val onSuccess: (() -> Unit), val onMaxAmountSelected: (() -> Unit)? = null, val onShowPermissionBottomSheet: () -> Unit = {}, ) +@Immutable sealed class SwapCardState { + abstract val type: TransactionCardType + data class SwapCardData( - @DrawableRes val networkIconRes: Int?, - val type: TransactionCardType, + override val type: TransactionCardType, + val currencyIconState: CurrencyIconState, + val tokenSymbol: TextReference, val amountEquivalent: TextReference?, - val token: CryptoCurrencyStatus?, - val coinId: String?, val amountTextFieldValue: TextFieldValue?, - val tokenIconUrl: String?, - val tokenCurrency: String, val balance: String, val isBalanceHidden: Boolean, - val isNotNativeToken: Boolean, - val canSelectAnotherToken: Boolean = false, ) : SwapCardState() data class Empty( - val type: TransactionCardType, - val amountEquivalent: TextReference?, + override val type: TransactionCardType, + val amountEquivalent: TextReference, val amountTextFieldValue: TextFieldValue?, - val canSelectAnotherToken: Boolean = false, + ) : SwapCardState() + + data class Loading( + override val type: TransactionCardType, ) : SwapCardState() } @@ -79,21 +78,21 @@ data class SwapButton( @Immutable sealed interface TransactionCardType { - val accountTitleUM: AccountTitleUM? + val accountTitleUM: AccountTitleUM val inputError: InputError data class Inputtable( val onAmountChanged: ((String) -> Unit), val onFocusChanged: ((Boolean) -> Unit), override val inputError: InputError, - override val accountTitleUM: AccountTitleUM?, + override val accountTitleUM: AccountTitleUM, ) : TransactionCardType data class ReadOnly( val shouldShowWarning: Boolean = false, val onWarningClick: (() -> Unit)? = null, override val inputError: InputError = InputError.Empty, - override val accountTitleUM: AccountTitleUM? = null, + override val accountTitleUM: AccountTitleUM, ) : TransactionCardType sealed interface InputError { @@ -116,4 +115,14 @@ data class LegalState( enum class ChangeCardsButtonState { ENABLED, DISABLED, UPDATE_IN_PROGRESS +} + +sealed class SwapPermissionUM { + + data class PermissionRequired( + val isResetApproval: Boolean, + val spenderAddress: String, + ) : SwapPermissionUM() + + object Empty : SwapPermissionUM() } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt new file mode 100644 index 0000000000..67761cb85d --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.models + +internal enum class TokenSelectionDirection { + FROM, + TO, +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index f6bddc0a70..ac922cd6f9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -1,33 +1,28 @@ package com.tangem.feature.swap.models -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.TxFee import java.math.BigDecimal -data class UiActions( +internal data class UiActions( val onAmountChanged: (String) -> Unit, val onAmountSelected: (Boolean) -> Unit, val onSwapClick: () -> Unit, - val onGivePermissionClick: () -> Unit, val onChangeCardsClicked: () -> Unit, val onBackClicked: () -> Unit, val onMaxAmountSelected: () -> Unit, val onReduceToAmount: (SwapAmount) -> Unit, val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit, val openPermissionBottomSheet: () -> Unit, - val onChangeApproveType: (ApproveType) -> Unit, // region new actions val onRetryClick: () -> Unit, val onClickFee: () -> Unit, val onSelectFeeType: (TxFee.Legacy) -> Unit, val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, - val onBuyClick: (CryptoCurrency) -> Unit, - val onSelectTokenClick: () -> Unit, + val onBuyClick: () -> Unit, + val onSelectTokenClick: (TokenSelectionDirection) -> Unit, val onSuccess: () -> Unit, val onLinkClick: (String) -> Unit, val onReceiveCardWarningClick: () -> Unit, - val onOpenLearnMoreAboutApproveClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 96a0293a82..ff6f8b47ce 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -4,7 +4,7 @@ import com.tangem.common.ui.R import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.models.currency.CryptoCurrency @@ -60,7 +60,7 @@ internal object SwapNotificationUM { val currencyName: String, val currencySymbol: String, val feeCurrency: CryptoCurrency?, - val onConfirmClick: (CryptoCurrency) -> Unit, + val onConfirmClick: () -> Unit, ) : Error( title = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_title, @@ -74,7 +74,7 @@ internal object SwapNotificationUM { buttonState = feeCurrency?.let { NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)), - onClick = { onConfirmClick(it) }, + onClick = onConfirmClick, ) }, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt new file mode 100644 index 0000000000..9826e46d2f --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.router + +import com.tangem.core.decompose.navigation.Route + +internal sealed interface SwapRoute : Route { + data object Main : SwapRoute + data object Success : SwapRoute + data class SelectToken(val isFromDirection: Boolean) : SwapRoute +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt deleted file mode 100644 index a3a55feafd..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.tangem.feature.swap.router - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRouter -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId - -internal class SwapRouter( - private val router: AppRouter, -) { - - var currentScreen by mutableStateOf(SwapNavScreen.Main) - private set - - fun openScreen(screen: SwapNavScreen) { - currentScreen = screen - } - - fun back() { - if (currentScreen == SwapNavScreen.SelectToken) { - currentScreen = SwapNavScreen.Main - } else { - val selectTokensIndex = router.stack.getSelectTokensRouteIndexOrNull() - - /* - * If select token screen is not in stack, then just pop to previous screen. - * Otherwise, pop to previous screen that was before select token screen. - */ - if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) { - // find previous screen that was before select token - val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1) - - if (prevRoute != null) { - router.popTo(prevRoute) - } else { - router.pop() - } - } else { - router.pop() - } - } - } - - fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) { - val route = AppRoute.CurrencyDetails( - userWalletId = userWalletId, - currency = currency, - ) - - if (route in router.stack) { - router.popTo(route) - } else { - router.pop { - router.push(route) - } - } - } - - private fun List.getSelectTokensRouteIndexOrNull(): Int? { - return this - .indexOfFirst { it::class == AppRoute.SwapCrypto::class } - .takeIf { it != -1 } - } -} - -enum class SwapNavScreen { - Main, Success, SelectToken -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 1f953197d9..db56f3b0a1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -3,15 +3,14 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM -import com.tangem.common.ui.bottomsheet.permission.state.* +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme @@ -19,14 +18,14 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isHotWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.NetworkInfo +import com.tangem.feature.swap.domain.models.domain.RateType import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapNotificationsFactory @@ -44,84 +43,48 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal import java.math.RoundingMode -import java.util.Locale import kotlin.math.min /** * State builder creates a specific states for SwapScreen */ -@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") +@Suppress("LargeClass", "TooManyFunctions") internal class StateBuilder( - private val userWalletProvider: Provider, private val actions: UiActions, private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, - holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) { - - private val isHoldToConfirmEnabled: Boolean = - holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWalletProvider().isHotWallet - private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) { SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork) } - fun createInitialLoadingState( - initialCurrencyFrom: CryptoCurrency, - initialCurrencyTo: CryptoCurrency?, - fromNetworkInfo: NetworkInfo, - ): SwapStateHolder { + fun createInitialLoadingState(): SwapStateHolder { return SwapStateHolder( - blockchainId = fromNetworkInfo.blockchainId, - sendCardData = SwapCardState.SwapCardData( - type = TransactionCardType.Inputtable( - onAmountChanged = actions.onAmountChanged, - onFocusChanged = actions.onAmountSelected, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountEquivalent = null, - amountTextFieldValue = null, - token = null, - tokenIconUrl = initialCurrencyFrom.iconUrl, - tokenCurrency = initialCurrencyFrom.symbol, - coinId = initialCurrencyFrom.network.backendId, - canSelectAnotherToken = false, - isNotNativeToken = initialCurrencyFrom is CryptoCurrency.Token, - balance = "", - networkIconRes = getActiveIconRes(initialCurrencyFrom.network.rawId), - isBalanceHidden = true, + sendCardData = getEmptyCardState( + isFromCard = true, + emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY), ), - receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), - amountEquivalent = null, - tokenIconUrl = initialCurrencyTo?.iconUrl, - tokenCurrency = initialCurrencyTo?.symbol.orEmpty(), - token = null, - amountTextFieldValue = null, - canSelectAnotherToken = false, - balance = "", - isNotNativeToken = initialCurrencyTo is CryptoCurrency.Token, - networkIconRes = initialCurrencyTo?.let { getActiveIconRes(it.network.rawId) }, - coinId = initialCurrencyTo?.network?.backendId, - isBalanceHidden = true, + receiveCardData = getEmptyCardState( + isFromCard = false, + emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY), ), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = null, isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isInProgress = true, + isHoldToConfirm = false, onClick = {}, ), onRefresh = {}, onBackClicked = actions.onBackClicked, onChangeCardsClicked = actions.onChangeCardsClicked, onMaxAmountSelected = actions.onMaxAmountSelected, - changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, + changeCardsButtonState = ChangeCardsButtonState.DISABLED, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, onSelectTokenClick = actions.onSelectTokenClick, onSuccess = actions.onSuccess, @@ -132,101 +95,214 @@ internal class StateBuilder( ) } - fun createNoAvailableTokensToSwapState( + fun createInitialReadyState( uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrencyStatus, + emptyAmountState: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, ): SwapStateHolder { - if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), - amountTextFieldValue = null, - amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = fromToken, - tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.backendId, - isNotNativeToken = fromToken.currency is CryptoCurrency.Token, - tokenCurrency = fromToken.currency.symbol, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = getActiveIconRes(fromToken.currency.network.rawId), - isBalanceHidden = isBalanceHiddenProvider(), + sendCardData = createCardState( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, ), - receiveCardData = SwapCardState.Empty( - type = TransactionCardType.ReadOnly(), - amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - amountTextFieldValue = TextFieldValue( - text = "0", - ), - canSelectAnotherToken = true, + receiveCardData = createCardState( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, ), - notifications = notificationsFactory.getNotAvailableStateNotifications(fromToken.currency.name), + notifications = persistentListOf(), + isInsufficientFunds = false, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, onClick = { }, ), - changeCardsButtonState = ChangeCardsButtonState.DISABLED, + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, ) } + fun updateCurrenciesState( + uiStateHolder: SwapStateHolder, + emptyAmountState: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + shouldResetAmount: Boolean, + ): SwapStateHolder { + return uiStateHolder.copy( + sendCardData = uiStateHolder.sendCardData.updateCurrencyStatus( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, + shouldResetAmount = shouldResetAmount, + ), + receiveCardData = uiStateHolder.receiveCardData.updateCurrencyStatus( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, + shouldResetAmount = shouldResetAmount, + ), + notifications = persistentListOf(), + isInsufficientFunds = false, + fee = FeeItemState.Empty, + swapButton = SwapButton( + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), + isEnabled = false, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, + onClick = { }, + ), + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty, + ) + } + + private fun SwapCardState.updateCurrencyStatus( + swapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, + shouldResetAmount: Boolean, + isFromCard: Boolean, + ): SwapCardState { + val cardType = if (isFromCard) { + TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true), + ) + } else { + TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, false), + ) + } + return if (this !is SwapCardState.SwapCardData || swapCurrencyStatus == null) { + createCardState( + swapCurrencyStatus = swapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = isFromCard, + ) + } else if (shouldResetAmount) { + copy( + amountTextFieldValue = if (isFromCard) { + null + } else { + TextFieldValue("0".appendApproximateSign()) + }, + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + type = cardType, + ) + } else { + copy( + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + type = cardType, + ) + } + } + + private fun createCardState( + swapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, + isFromCard: Boolean, + ): SwapCardState { + return if (swapCurrencyStatus == null) { + getEmptyCardState(isFromCard = isFromCard, emptyAmountState = emptyAmountState) + } else { + SwapCardState.SwapCardData( + type = if (isFromCard) { + TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true), + ) + } else { + TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, false), + ) + }, + amountTextFieldValue = if (isFromCard) { + null + } else { + TextFieldValue("0".appendApproximateSign()) + }, + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + ) + } + } + + private fun getEmptyCardState(isFromCard: Boolean, emptyAmountState: SwapState.EmptyAmountState) = + SwapCardState.Empty( + type = TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text( + title = resourceReference( + if (isFromCard) R.string.swapping_from_title_v2 else R.string.swapping_to_title, + ), + ), + ), + amountTextFieldValue = TextFieldValue(text = if (isFromCard) "0" else "0".appendApproximateSign()), + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + ) + fun createSwapNotSupportedState( uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, - mainTokenId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.currency.id.value - val canSelectReceiveToken = mainTokenId != toToken.currency.id.value if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( - accountTitleUM = getFromCardAccountTitle(fromAccount), + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), ), amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = fromToken, - tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.backendId, - isNotNativeToken = fromToken.currency is CryptoCurrency.Token, - tokenCurrency = fromToken.currency.symbol, - canSelectAnotherToken = canSelectSendToken, - balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = getActiveIconRes(fromToken.currency.network.rawId), + currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = toToken, - tokenIconUrl = toToken.currency.iconUrl, - coinId = toToken.currency.network.backendId, - isNotNativeToken = toToken.currency is CryptoCurrency.Token, - tokenCurrency = toToken.currency.symbol, - canSelectAnotherToken = canSelectReceiveToken, - balance = toToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = getActiveIconRes(toToken.currency.network.rawId), + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), + balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), notifications = notificationsFactory.getSwapNotSupportedNotifications(), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = { }, ), changeCardsButtonState = ChangeCardsButtonState.DISABLED, @@ -237,115 +313,76 @@ internal class StateBuilder( @Suppress("LongParameterList") fun createQuotesLoadingState( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrency, - toToken: CryptoCurrency, - mainTokenId: String, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.id.value - val canSelectReceiveToken = mainTokenId != toToken.id.value + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder - val sendInputType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable) - val sendInput = if (sendInputType.inputError !is TransactionCardType.InputError.Empty) { - sendInputType - } else { - sendInputType.copy( - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = getFromCardAccountTitle(fromAccount), - ) - } return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = sendInput, - amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = null, - token = uiStateHolder.sendCardData.token, - tokenIconUrl = fromToken.iconUrl, - tokenCurrency = fromToken.symbol, - coinId = fromToken.network.backendId, - isNotNativeToken = fromToken is CryptoCurrency.Token, - canSelectAnotherToken = canSelectSendToken, - balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", - networkIconRes = getActiveIconRes(fromToken.network.rawId), - isBalanceHidden = isBalanceHiddenProvider(), + sendCardData = uiStateHolder.sendCardData.copy( + type = TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + ), ), - receiveCardData = SwapCardState.SwapCardData( + receiveCardData = uiStateHolder.receiveCardData.copy( type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = null, amountEquivalent = null, - token = uiStateHolder.receiveCardData.token, - tokenIconUrl = toToken.iconUrl, - tokenCurrency = toToken.symbol, - coinId = toToken.network.backendId, - isNotNativeToken = toToken is CryptoCurrency.Token, - canSelectAnotherToken = canSelectReceiveToken, - balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", - networkIconRes = getActiveIconRes(toToken.network.rawId), - isBalanceHidden = isBalanceHiddenProvider(), ), notifications = persistentListOf(), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = {}, ), providerState = ProviderState.Loading(), - permissionState = uiStateHolder.permissionState, + permissionUM = uiStateHolder.permissionUM, changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, priceImpact = PriceImpact.Empty, - shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toToken), + shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency), ) } - /** - * Create quotes loaded state - * - * @param uiStateHolder whole screen state - * @param quoteModel data model - * @param fromToken token data to swap - * @return updated whole screen state - */ @Suppress("LongMethod", "LongParameterList") fun createQuotesLoadedState( uiStateHolder: SwapStateHolder, quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, swapProvider: SwapProvider, bestRatedProviderId: String, isNeedBestRateBadge: Boolean, selectedFeeType: FeeType, - isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, hideFee: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType) + val fromSwapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus + val toSwapCurrencyStatus = quoteModel.toTokenInfo.swapCurrencyStatus + val isInsufficientFunds = isInsufficientFundsCondition(quoteModel) + val notifications = notificationsFactory.getConfirmationStateNotifications( quoteModel = quoteModel, - fromToken = fromToken, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, selectedFeeType = selectedFeeType, providerName = swapProvider.name, hideFee = hideFee, ) - val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType) - val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus - val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus - val isInsufficientFunds = isInsufficientFundsCondition(quoteModel) - val fromAccount = quoteModel.fromTokenInfo.account - val toAccount = quoteModel.toTokenInfo.account val fromAccountTitleUM = when { isInsufficientFunds -> AccountTitleUM.Text(TextReference.Res(R.string.swapping_insufficient_funds)) - else -> getFromCardAccountTitle(fromAccount) + else -> getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true) } val sendCardType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable) val sendInput = when (sendCardType.inputError) { @@ -370,22 +407,17 @@ internal class StateBuilder( sendCardData = SwapCardState.SwapCardData( type = sendInput, amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), - token = fromCurrencyStatus, - tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = fromCurrencyStatus.currency.network.backendId, - isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.sendCardData.networkIconRes, - balance = fromCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + amountEquivalent = uiStateHolder.sendCardData.amountEquivalent, + currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( shouldShowWarning = true, onWarningClick = actions.onReceiveCardWarningClick, - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = TextFieldValue( quoteModel.toTokenInfo.tokenAmount @@ -412,34 +444,24 @@ internal class StateBuilder( } else { getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat) }, - token = toCurrencyStatus, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toCurrencyStatus.currency.network.backendId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), + balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), isInsufficientFunds = isInsufficientFundsCondition(quoteModel), notifications = notifications, - permissionState = convertPermissionState( - lastPermissionState = uiStateHolder.permissionState, + permissionUM = convertPermissionState( permissionDataState = quoteModel.permissionState, - providerName = swapProvider.name, - onGivePermissionClick = actions.onGivePermissionClick, - onChangeApproveType = actions.onChangeApproveType, - onOpenLearnMoreAboutApproveClick = actions.onOpenLearnMoreAboutApproveClick, ), fee = feeState, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = getSwapButtonEnabled(notifications, priceImpact), - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = actions.onSwapClick, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = swapProvider.convertToContentClickableProviderState( isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(), fromTokenInfo = quoteModel.fromTokenInfo, @@ -452,12 +474,12 @@ internal class StateBuilder( ), priceImpact = priceImpact, tosState = createTosState(swapProvider), - shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrencyStatus.currency), + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency), ) } - private fun shouldShowMaxAmount(fromToken: CryptoCurrency, toCurrency: CryptoCurrency): Boolean { - return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency.network.id) + private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean { + return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id) } private fun createTosState(swapProvider: SwapProvider): TosState { @@ -501,72 +523,65 @@ internal class StateBuilder( uiStateHolder: SwapStateHolder, swapProvider: SwapProvider, fromToken: TokenSwapInfo, - toToken: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, + toSwapCurrencyStatus: SwapCurrencyStatus?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, - isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val fromSwapCurrencyStatus = fromToken.swapCurrencyStatus + val notifications = notificationsFactory.getQuotesErrorStateNotifications( expressDataError = expressDataError, - fromToken = fromToken.cryptoCurrencyStatus.currency, + fromToken = fromSwapCurrencyStatus.currency, feeItem = uiStateHolder.fee, includeFeeInAmount = includeFeeInAmount, ) val providerState = getProviderStateForError( swapProvider = swapProvider, - fromToken = fromToken.cryptoCurrencyStatus.currency, + fromToken = fromSwapCurrencyStatus.currency, expressDataError = expressDataError, onProviderClick = actions.onProviderClick, selectionType = ProviderState.SelectionType.CLICK, needApplyFCARestrictions = needApplyFCARestrictions, ) - val type = TransactionCardType.ReadOnly(accountTitleUM = getToCardAccountTitle(toAccount)) - val receiveCardData = toToken?.let { + val type = TransactionCardType.ReadOnly( + accountTitleUM = getCardAccountTitle( + toSwapCurrencyStatus?.account, + isFromCard = false, + ), + ) + val receiveCardData = toSwapCurrencyStatus?.status?.let { toToken -> SwapCardState.SwapCardData( type = type, amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = toToken, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toToken.currency.network.backendId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), balance = toToken.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ) } ?: SwapCardState.Empty( type = type, amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - amountTextFieldValue = TextFieldValue( - text = "0", - ), - canSelectAnotherToken = true, + amountTextFieldValue = null, ) return uiStateHolder.copy( - sendCardData = uiStateHolder.sendCardData.copy( - amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat), - balance = fromToken.cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - ), receiveCardData = receiveCardData, notifications = notifications, - permissionState = GiveTxPermissionState.Empty, + permissionUM = SwapPermissionUM.Empty, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = actions.onSwapClick, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = providerState, priceImpact = PriceImpact.Empty, tosState = createTosState(swapProvider), @@ -613,58 +628,32 @@ internal class StateBuilder( } } - @Suppress("LongParameterList") fun createQuotesEmptyAmountState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, - fromTokenStatus: CryptoCurrencyStatus, - toTokenStatus: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, - isReverseSwapPossible: Boolean, + fromSwapCurrencyStatus: SwapCurrencyStatus?, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), + sendCardData = uiStateHolder.sendCardData.copy( amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = emptyAmountState.zeroAmountEquivalent, - token = uiStateHolder.sendCardData.token, - tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = uiStateHolder.sendCardData.coinId, - isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.sendCardData.networkIconRes, - balance = fromTokenStatus.getFormattedAmount(isNeedSymbol = false), - isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), - ), + receiveCardData = uiStateHolder.receiveCardData.copy( amountTextFieldValue = TextFieldValue("0"), amountEquivalent = emptyAmountState.zeroAmountEquivalent, - token = uiStateHolder.receiveCardData.token, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = uiStateHolder.receiveCardData.coinId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toTokenStatus?.getFormattedAmount(isNeedSymbol = false) ?: DASH_SIGN, - isBalanceHidden = isBalanceHiddenProvider(), ), notifications = persistentListOf(), isInsufficientFunds = false, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, onClick = { }, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, ) @@ -693,15 +682,14 @@ internal class StateBuilder( uiState: SwapStateHolder, amountFormatted: String, amountRaw: String, - fromToken: CryptoCurrency, - fromAccount: Account.CryptoPortfolio?, + fromSwapCurrencyStatus: SwapCurrencyStatus, minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState val amountToSend = amountRaw.toBigDecimalOrNull() val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) { val minAmountFormatted = minTxAmount.format { - crypto(cryptoCurrency = fromToken, ignoreSymbolPosition = true) + crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true) } (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.WrongAmount, @@ -712,7 +700,7 @@ internal class StateBuilder( } else { (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.Empty, - accountTitleUM = getFromCardAccountTitle(fromAccount), + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), ) ?: uiState.sendCardData.type } return uiState.copy( @@ -721,48 +709,46 @@ internal class StateBuilder( text = amountFormatted, selection = TextRange(amountFormatted.length), ), + amountEquivalent = getFormattedFiatAmount( + fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate -> + amountToSend?.multiply(fiatRate) + }, + ), type = sendInput, ), ) } - fun updateSendCurrencyBalance( + fun updateCurrencyBalanceStatus( uiState: SwapStateHolder, - cryptoCurrencyStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, ): SwapStateHolder { - if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - return uiState.copy( - sendCardData = uiState.sendCardData.copy( - balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - token = cryptoCurrencyStatus, + sendCardData = uiState.sendCardData.updateCurrencyStatus( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, + shouldResetAmount = false, ), - ) - } - - fun updateReceiveCurrencyBalance( - uiState: SwapStateHolder, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): SwapStateHolder { - if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState - - return uiState.copy( - receiveCardData = uiState.receiveCardData.copy( - balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - token = cryptoCurrencyStatus, + receiveCardData = uiState.receiveCardData.updateCurrencyStatus( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, + shouldResetAmount = false, ), ) } fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder { - if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState - val patchedSendCardData = uiState.sendCardData.copy( + val patchedSendCardData = (uiState.sendCardData as? SwapCardState.SwapCardData)?.copy( isBalanceHidden = isBalanceHidden, - ) - val patchedReceiveCardData = uiState.receiveCardData.copy( + ) ?: uiState.sendCardData + + val patchedReceiveCardData = (uiState.receiveCardData as? SwapCardState.SwapCardData)?.copy( isBalanceHidden = isBalanceHidden, - ) + ) ?: uiState.receiveCardData return uiState.copy( sendCardData = patchedSendCardData, @@ -770,32 +756,6 @@ internal class StateBuilder( ) } - fun updateApproveType(uiState: SwapStateHolder, approveType: ApproveType): SwapStateHolder { - val config = uiState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig - val permissionState = (uiState.permissionState as? GiveTxPermissionState.ReadyForRequest)?.copy( - approveType = approveType, - ) ?: uiState.permissionState - return if (config != null) { - uiState.copy( - permissionState = permissionState, - bottomSheetConfig = uiState.bottomSheetConfig.copy( - content = config.copy( - data = config.data.copy(approveType = approveType), - ), - ), - ) - } else { - uiState - } - } - - fun createInitialErrorState(uiState: SwapStateHolder, code: Int, onRefreshClick: () -> Unit): SwapStateHolder { - return uiState.copy( - isInsufficientFunds = false, - notifications = notificationsFactory.getInitialErrorStateNotifications(code, onRefreshClick), - ) - } - private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState { val isClickable: Boolean val fee = when (txFeeState) { @@ -834,7 +794,6 @@ internal class StateBuilder( isEnabled = false, isInProgress = false, ), - permissionState = GiveTxPermissionState.InProgress, notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications), ) } @@ -848,16 +807,17 @@ internal class StateBuilder( onStatusClick: () -> Unit, txUrl: String, ): SwapStateHolder { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) - val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO val providerState = uiState.providerState as ProviderState.Content - val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) - val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) + val fromFiatAmount = getFormattedFiatAmount(fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount)) val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName + val isFloatRate = dataState.selectedProvider?.rateTypes?.contains(RateType.FLOAT) == true return uiState.copy( successState = SwapSuccessStateHolder( timestamp = swapTransactionState.timestamp, @@ -870,14 +830,17 @@ internal class StateBuilder( fee = dataState.selectedFee?.let { fee -> stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})") }, - fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), - toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), + fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), - toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), + toTokenAmount = stringReference( + swapTransactionState.toAmount.orEmpty() + .let { if (isFloatRate) it.appendApproximateSign() else it }, + ), fromTokenFiatAmount = fromFiatAmount, toTokenFiatAmount = toFiatAmount, - fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), - toTokenIconState = iconStateConverter.convert(toCryptoCurrency), + fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), onExploreButtonClick = onExploreClick, onStatusButtonClick = onStatusClick, ), @@ -891,14 +854,14 @@ internal class StateBuilder( txUrl: String, onExploreClick: () -> Unit, ): SwapStateHolder { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) - val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO val providerState = uiState.providerState as ProviderState.Content - val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) - val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) + val fromFiatAmount = getFormattedFiatAmount(fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount)) return uiState.copy( successState = SwapSuccessStateHolder( @@ -910,14 +873,14 @@ internal class StateBuilder( providerIcon = providerState.iconUrl, rate = providerState.subtitle, fee = TextReference.EMPTY, - fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), - toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), + fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), fromTokenFiatAmount = fromFiatAmount, toTokenFiatAmount = toFiatAmount, - fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), - toTokenIconState = iconStateConverter.convert(toCryptoCurrency), + fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), onExploreButtonClick = onExploreClick, onStatusButtonClick = {}, ), @@ -934,74 +897,14 @@ internal class StateBuilder( } @Suppress("LongParameterList") - private fun convertPermissionState( - lastPermissionState: GiveTxPermissionState, - permissionDataState: PermissionDataState, - providerName: String, - onGivePermissionClick: () -> Unit, - onChangeApproveType: (ApproveType) -> Unit, - onOpenLearnMoreAboutApproveClick: () -> Unit, - ): GiveTxPermissionState { - val approveType = if (lastPermissionState is GiveTxPermissionState.ReadyForRequest) { - lastPermissionState.approveType - } else { - ApproveType.UNLIMITED - } + private fun convertPermissionState(permissionDataState: PermissionDataState): SwapPermissionUM { return when (permissionDataState) { - PermissionDataState.Empty -> GiveTxPermissionState.Empty - PermissionDataState.PermissionFailed -> GiveTxPermissionState.Empty - PermissionDataState.PermissionLoading -> GiveTxPermissionState.InProgress - is PermissionDataState.PermissionReadyForRequest -> { - val permissionFee = when (val fee = permissionDataState.requestApproveData.fee) { - TxFeeState.Empty -> error("Fee shouldn't be empty") - is TxFeeState.MultipleFeeState -> fee.priorityFee - is TxFeeState.SingleFeeState -> fee.fee - } - GiveTxPermissionState.ReadyForRequest( - currency = permissionDataState.currency, - amount = permissionDataState.amount, - approveType = approveType, - walletAddress = getShortAddressValue(permissionDataState.walletAddress), - spenderAddress = getShortAddressValue(permissionDataState.spenderAddress), - fee = TextReference.Str("${permissionFee.feeCryptoFormatted} (${permissionFee.feeFiatFormatted})"), - approveButton = ApprovePermissionButton( - isEnabled = true, - onClick = onGivePermissionClick, - ), - cancelButton = CancelPermissionButton( - enabled = true, - ), - onChangeApproveType = onChangeApproveType, - subtitle = resourceReference( - id = R.string.give_permission_swap_subtitle, - formatArgs = wrappedList(providerName, permissionDataState.currency), - ), - dialogText = resourceReference(R.string.swapping_approve_information_text), - footerText = resourceReference(R.string.swap_give_permission_fee_footer), - onOpenLearnMoreAboutApproveClick = onOpenLearnMoreAboutApproveClick, - isResetApproval = permissionDataState.isResetApproval, - ) - } - } - } - - fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder { - val permissionState = uiState.permissionState - if (permissionState is GiveTxPermissionState.ReadyForRequest) { - val config = GiveTxPermissionBottomSheetConfig( - data = permissionState, - onCancel = onDismiss, - walletInteractionIcon = walletInterationIcon(userWalletProvider()), - ) - return uiState.copy( - bottomSheetConfig = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = onDismiss, - content = config, - ), + is PermissionDataState.PermissionRequired -> SwapPermissionUM.PermissionRequired( + isResetApproval = permissionDataState.isResetApproval, + spenderAddress = permissionDataState.spenderAddress, ) + else -> SwapPermissionUM.Empty } - return uiState } fun dismissBottomSheet(uiState: SwapStateHolder): SwapStateHolder { @@ -1062,7 +965,7 @@ internal class StateBuilder( val tokenInfo = tokenSwapInfoForProviders[providerState.id] if (providerState is ProviderState.Content && tokenInfo != null) { val rateString = tokenInfo.tokenAmount - .getFormattedCryptoAmount(tokenInfo.cryptoCurrencyStatus.currency) + .getFormattedCryptoAmount(tokenInfo.swapCurrencyStatus.currency) providerState.copy( subtitle = stringReference(rateString), percentLowerThenBest = pricesLowerBest[providerState.id]?.let { percent -> @@ -1085,6 +988,7 @@ internal class StateBuilder( uiState: SwapStateHolder, selectedFee: FeeType, txFeeState: TxFeeState.MultipleFeeState, + readMoreUrl: String, onDismiss: () -> Unit, ): SwapStateHolder { val config = ChooseFeeBottomSheetConfig( @@ -1096,7 +1000,7 @@ internal class StateBuilder( } actions.onSelectFeeType.invoke(selectedItem) }, - readMoreUrl = buildReadMoreUrl(), + readMoreUrl = readMoreUrl, feeItems = txFeeState.toFeeItemState(), readMore = resourceReference(R.string.common_read_more), onReadMoreClick = actions.onLinkClick, @@ -1110,15 +1014,6 @@ internal class StateBuilder( ) } - @Deprecated("Use TangemBlockUrlBuilder instead") - private fun buildReadMoreUrl(): String { - return buildString { - append(FEE_READ_MORE_URL_FIRST_PART) - append(getLocaleName()) - append(FEE_READ_MORE_URL_SECOND_PART) - } - } - private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList { return listOf( FeeItemState.Content( @@ -1161,7 +1056,7 @@ internal class StateBuilder( } is SwapState.SwapError -> getProviderStateForError( swapProvider = provider, - fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency, + fromToken = state.fromTokenInfo.swapCurrencyStatus.currency, expressDataError = state.error, onProviderClick = onProviderSelect, selectionType = ProviderState.SelectionType.SELECT, @@ -1170,16 +1065,6 @@ internal class StateBuilder( } } - private fun getShortAddressValue(fullAddress: String): String { - check(fullAddress.length > ADDRESS_MIN_LENGTH) { "Invalid address" } - val firstAddressPart = fullAddress.substring(startIndex = 0, endIndex = ADDRESS_FIRST_PART_LENGTH) - val secondAddressPart = fullAddress.substring( - startIndex = fullAddress.length - ADDRESS_SECOND_PART_LENGTH, - endIndex = fullAddress.length, - ) - return "$firstAddressPart...$secondAddressPart" - } - @Suppress("LongParameterList") private fun SwapProvider.convertToContentClickableProviderState( isBestRate: Boolean, @@ -1193,18 +1078,18 @@ internal class StateBuilder( ): ProviderState { val rate = toTokenInfo.tokenAmount.value.calculateRate( fromTokenInfo.tokenAmount.value, - toTokenInfo.cryptoCurrencyStatus.currency.decimals, + toTokenInfo.swapCurrencyStatus.currency.decimals, ) - val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol + val fromCurrencySymbol = fromTokenInfo.swapCurrencyStatus.currency.symbol val rateString = buildString { append(BigDecimal.ONE.format { crypto(symbol = fromCurrencySymbol, decimals = 0).anyDecimals() }) append(" ≈ ") - append(rate.format { crypto(toTokenInfo.cryptoCurrencyStatus.currency) }) + append(rate.format { crypto(toTokenInfo.swapCurrencyStatus.currency) }) } val additionalBadge = when { needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList - permissionState is PermissionDataState.PermissionReadyForRequest -> + permissionState is PermissionDataState.PermissionRequired -> ProviderState.AdditionalBadge.PermissionRequired isRecommended -> ProviderState.AdditionalBadge.Recommended isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions -> ProviderState.AdditionalBadge.BestTrade @@ -1233,11 +1118,11 @@ internal class StateBuilder( needApplyFCARestrictions: Boolean, ): ProviderState { val toTokenInfo = state.toTokenInfo - val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency) + val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.swapCurrencyStatus.currency) val additionalBadge = when { needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList - state.permissionState is PermissionDataState.PermissionReadyForRequest -> { + state.permissionState is PermissionDataState.PermissionRequired -> { ProviderState.AdditionalBadge.PermissionRequired } isRecommended -> ProviderState.AdditionalBadge.Recommended @@ -1287,8 +1172,8 @@ internal class StateBuilder( ) } - private fun CryptoCurrencyStatus.getFormattedAmount(isNeedSymbol: Boolean): String { - val amount = value.amount ?: return DASH_SIGN + private fun CryptoCurrencyStatus?.getFormattedAmount(isNeedSymbol: Boolean): String { + val amount = this?.value?.amount ?: return DASH_SIGN val symbol = if (isNeedSymbol) currency.symbol else "" return amount.format { crypto(symbol, currency.decimals) } } @@ -1315,14 +1200,6 @@ internal class StateBuilder( return this.divide(to, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP) } - private fun getLocaleName(): String { - return if (Locale.getDefault().language == "ru") { - RU_LOCALE - } else { - EN_LOCALE - } - } - private fun String.appendApproximateSign(): String { return "$TILDE_SIGN $this" } @@ -1331,46 +1208,33 @@ internal class StateBuilder( return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId) } - private fun getFromCardAccountTitle(fromAccount: Account.CryptoPortfolio?): AccountTitleUM { - return if (fromAccount != null && isAccountsModeProvider()) { + private fun getCardAccountTitle(account: Account?, isFromCard: Boolean): AccountTitleUM { + val (prefix, placeholder) = if (isFromCard) { + R.string.swapping_from_account_title to R.string.swapping_from_title_v2 + } else { + R.string.swapping_to_account_title to R.string.swapping_to_title + } + return if (account != null && isAccountsModeProvider()) { AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = fromAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(fromAccount.icon), + prefixText = resourceReference(prefix), + name = account.accountName.toUM().value, + icon = account.toIconUM(), ) } else { - AccountTitleUM.Text(resourceReference(R.string.swapping_from_title)) + AccountTitleUM.Text(resourceReference(placeholder)) } } - private fun getToCardAccountTitle(toAccount: Account.CryptoPortfolio?): AccountTitleUM { - return if (toAccount != null && isAccountsModeProvider()) { - AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_to), - name = toAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(toAccount.icon), - ) - } else { - AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)) + private fun Account.toIconUM(): AccountIconUM { + return when (this) { + is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon) + is Account.Payment -> AccountIconUM.Payment } } - private fun getChangeCardsButtonState(isReverseSwapPossible: Boolean) = if (isReverseSwapPossible) { - ChangeCardsButtonState.ENABLED - } else { - ChangeCardsButtonState.DISABLED - } - private companion object { - private const val RU_LOCALE = "ru" - private const val EN_LOCALE = "en" - const val ADDRESS_MIN_LENGTH = 11 - const val ADDRESS_FIRST_PART_LENGTH = 7 - const val ADDRESS_SECOND_PART_LENGTH = 4 private const val MAX_DECIMALS_TO_SHOW = 8 private const val IF_ZERO_DECIMALS_TO_SHOW = 2 - private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/" - private const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/" private val FCA_RESTRICTED_PROVIDER_IDS = setOf( "changelly", diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index 0a5f77d83d..593a6e4109 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -9,8 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag -import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -44,7 +42,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: feeBlock = if (feeSelectorBlockComponent != null) { @Composable { modifier: Modifier -> feeSelectorBlockComponent.Content( - modifier = Modifier + modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action), ) @@ -61,7 +59,6 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: val config = stateHolder.bottomSheetConfig when (config.content) { - is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config) is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config) is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 10d12201f0..322b60f85a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -24,17 +24,17 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.* import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags @@ -45,6 +45,8 @@ import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.receiveCard +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.sendCard import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @@ -127,22 +129,22 @@ private fun MainInfo(state: SwapStateHolder) { ) { val (topCard, bottomCard, button) = createRefs() val priceImpact = state.priceImpact - TransactionCardData( + TransactionCard( priceImpact = priceImpact, swapCardState = state.sendCardData, modifier = Modifier.constrainAs(topCard) { top.linkTo(parent.top) }, - onSelectTokenClick = state.onSelectTokenClick, + onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.FROM) }, ) val marginCard = TangemTheme.dimens.spacing12 - TransactionCardData( + TransactionCard( priceImpact = priceImpact, swapCardState = state.receiveCardData, modifier = Modifier.constrainAs(bottomCard) { top.linkTo(topCard.bottom, margin = marginCard) }, - onSelectTokenClick = state.onSelectTokenClick, + onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) }, ) val marginButton = TangemTheme.dimens.spacing30 SwapButton( @@ -156,43 +158,6 @@ private fun MainInfo(state: SwapStateHolder) { } } -@Composable -private fun TransactionCardData( - priceImpact: PriceImpact, - swapCardState: SwapCardState, - onSelectTokenClick: (() -> Unit)?, - modifier: Modifier = Modifier, -) { - when (swapCardState) { - is SwapCardState.Empty -> { - TransactionCardEmpty( - type = swapCardState.type, - amountEquivalent = swapCardState.amountEquivalent, - textFieldValue = swapCardState.amountTextFieldValue, - onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, - modifier = modifier, - ) - } - is SwapCardState.SwapCardData -> { - TransactionCard( - type = swapCardState.type, - balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden), - textFieldValue = swapCardState.amountTextFieldValue, - amountEquivalent = swapCardState.amountEquivalent, - tokenIconUrl = swapCardState.tokenIconUrl.orEmpty(), - tokenCurrency = swapCardState.tokenCurrency, - priceImpact = priceImpact, - networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null, - iconPlaceholder = swapCardState.coinId?.let { - getActiveIconResByCoinId(it) - }, - onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, - modifier = modifier, - ) - } - } -} - @Composable private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) { val tos = tosState.tosLink @@ -405,41 +370,6 @@ private fun MainButton(state: SwapStateHolder) { // region preview -private val sendCard = SwapCardState.SwapCardData( - type = TransactionCardType.Inputtable( - onAmountChanged = {}, - onFocusChanged = {}, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountTextFieldValue = TextFieldValue(), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - isNotNativeToken = true, - canSelectAnotherToken = false, - balance = "123", - coinId = "", - token = null, - networkIconRes = R.drawable.img_polygon_22, - isBalanceHidden = false, -) - -private val receiveCard = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), - amountTextFieldValue = TextFieldValue(), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - isNotNativeToken = true, - canSelectAnotherToken = true, - balance = "33333", - coinId = "", - token = null, - networkIconRes = R.drawable.img_polygon_22, - isBalanceHidden = false, -) - private val state = SwapStateHolder( sendCardData = sendCard, receiveCardData = receiveCard, @@ -464,14 +394,13 @@ private val state = SwapStateHolder( onRefresh = {}, onBackClicked = {}, onChangeCardsClicked = {}, - permissionState = GiveTxPermissionState.InProgress, - blockchainId = "POLYGON", + permissionUM = SwapPermissionUM.Empty, providerState = ProviderState.Loading(), priceImpact = PriceImpact.Empty, shouldShowMaxAmount = true, isInsufficientFunds = false, onSuccess = {}, - onSelectTokenClick = {}, + onSelectTokenClick = { _ -> }, tosState = TosState( tosLink = LegalState( title = stringReference("Terms of Use"), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt deleted file mode 100644 index 6758ca18b7..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ /dev/null @@ -1,457 +0,0 @@ -package com.tangem.feature.swap.ui - -import android.content.res.Configuration -import androidx.activity.compose.BackHandler -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.* -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.appbar.ExpandableSearchView -import com.tangem.core.ui.components.list.InfiniteListHandler -import com.tangem.core.ui.components.tokenlist.PortfolioListItem -import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem -import com.tangem.core.ui.components.tokenlist.TokenListItem -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.core.ui.decorations.roundedShapeItemDecoration -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.BuyTokenScreenTestTags -import com.tangem.core.ui.test.MainScreenTestTags -import com.tangem.core.ui.utils.lazyListItemPosition -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData -import com.tangem.feature.swap.models.isEmptyState -import com.tangem.feature.swap.models.isNotFoundState -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R -import com.tangem.feature.swap.ui.market.swapMarketsListItems -import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider -import kotlinx.collections.immutable.ImmutableList - -private const val LOAD_MORE_BUFFER = 25 - -@Composable -internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { - BackHandler(onBack = onBack) - - Scaffold( - modifier = Modifier - .systemBarsPadding() - .background(color = TangemTheme.colors.background.secondary), - content = { padding -> - val modifier = Modifier.padding(padding) - when { - state.isNotFoundState -> TokensNotFound(modifier) - state.isEmptyState -> EmptyTokensList(modifier) - state.marketsState != null -> ListOfTokensWithMarkets( - state = state, - marketsState = state.marketsState, - modifier = modifier, - ) - else -> ListOfTokens(state = state, modifier = modifier) - } - }, - topBar = { - ExpandableSearchView( - title = stringResourceSafe(R.string.common_choose_token), - onBackClick = onBack, - placeholderSearchText = stringResourceSafe(id = R.string.common_search_tokens), - onSearchChange = state.onSearchEntered, - onSearchDisplayClose = { state.onSearchEntered("") }, - subtitle = stringResourceSafe(id = R.string.express_exchange_token_list_subtitle), - ) - }, - ) -} - -@Composable -private fun EmptyTokensList(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .background(TangemTheme.colors.background.secondary) - .fillMaxSize(), - ) { - Column(modifier = Modifier.align(Alignment.Center)) { - Image( - modifier = Modifier - .size(TangemTheme.dimens.size64) - .align(Alignment.CenterHorizontally), - painter = painterResource(id = R.drawable.ic_no_token_44), - colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), - contentDescription = null, - ) - Text( - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing16) - .padding(horizontal = TangemTheme.dimens.spacing30) - .align(Alignment.CenterHorizontally), - text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - ) - } - } -} - -@Composable -private fun TokensNotFound(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .background(TangemTheme.colors.background.secondary) - .fillMaxSize(), - ) { - Text( - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing32) - .padding(horizontal = TangemTheme.dimens.spacing30) - .align(Alignment.TopCenter), - text = stringResourceSafe(id = R.string.express_token_list_empty_search), - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - ) - } -} - -@Composable -private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { - val screenBackgroundColor = TangemTheme.colors.background.secondary - - LazyColumn( - modifier = modifier - .background(color = screenBackgroundColor) - .fillMaxSize() - .imePadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - tokensListItems( - tokensListData = state.tokensListData, - isBalanceHidden = state.isBalanceHidden, - ) - } -} - -@Composable -private fun ListOfTokensWithMarkets( - state: SwapSelectTokenStateHolder, - marketsState: SwapMarketState, - modifier: Modifier = Modifier, -) { - val screenBackgroundColor = TangemTheme.colors.background.secondary - val lazyListState = rememberLazyListState() - - LazyColumn( - modifier = modifier - .background(color = screenBackgroundColor) - .fillMaxSize() - .imePadding(), - horizontalAlignment = Alignment.CenterHorizontally, - state = lazyListState, - ) { - if (state.tokensListData !is TokenListUMData.EmptyList) { - assetsTitle(count = state.tokensListData.totalTokensCount, showCount = marketsState.shouldAssetsCount) - } - - tokensListItems( - tokensListData = state.tokensListData, - isBalanceHidden = state.isBalanceHidden, - ) - - item("spacer_before_markets") { - SpacerH(32.dp) - } - - swapMarketsListItems(marketsState) - } - - (marketsState as? SwapMarketState.Content)?.let { content -> - VisibleItemsTracker( - lazyListState = lazyListState, - marketState = content, - ) - - InfiniteListHandler( - listState = lazyListState, - buffer = LOAD_MORE_BUFFER, - triggerLoadMoreCheckOnItemsCountChange = true, - onLoadMore = remember(content) { - { - content.loadMore() - true - } - }, - ) - } -} - -@Composable -private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) { - val visibleItems by remember { - derivedStateOf { - lazyListState.layoutInfo.visibleItemsInfo - .mapNotNull { itemInfo -> - marketState.items.find { it.getComposeKey() == itemInfo.key }?.id - } - } - } - - LaunchedEffect(visibleItems) { - marketState.visibleIdsChanged(visibleItems) - } -} - -private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) { - item(key = "assets_title") { - Text( - text = buildAnnotatedString { - append(stringResourceSafe(R.string.swap_your_assets_title)) - if (showCount) { - withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { - append(" $count") - } - } - }, - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .fillMaxWidth() - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - top = TangemTheme.dimens.spacing12, - ), - ) - } -} - -private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) { - when (tokensListData) { - is TokenListUMData.AccountList -> { - tokensListData.tokensList.forEachIndexed { index, item -> - portfolioTokensList( - portfolio = item, - isBalanceHidden = isBalanceHidden, - portfolioIndex = index, - modifier = Modifier, - ) - } - } - is TokenListUMData.TokenList -> { - tokensList( - items = tokensListData.tokensList, - isBalanceHidden = isBalanceHidden, - ) - } - TokenListUMData.EmptyList -> Unit - } -} - -private fun LazyListScope.tokensList(items: ImmutableList, isBalanceHidden: Boolean) { - itemsIndexed( - items = items, - key = { _, item -> item.id }, - contentType = { _, item -> item::class.java }, - itemContent = { index, item -> - TokenListItem( - state = item, - isBalanceHidden = isBalanceHidden, - modifier = Modifier - .roundedShapeItemDecoration( - currentIndex = index, - lastIndex = items.lastIndex, - backgroundColor = TangemTheme.colors.background.primary, - ) - .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) - .semantics { lazyListItemPosition = index }, - ) - }, - ) -} - -internal fun LazyListScope.portfolioTokensList( - portfolio: TokensListItemUM.Portfolio, - modifier: Modifier, - portfolioIndex: Int, - isBalanceHidden: Boolean, -) { - val tokens = portfolio.tokens - val isExpanded = portfolio.isExpanded - val lastIndex = tokens.lastIndex.inc() - - portfolioItem( - portfolio = portfolio, - modifier = modifier, - portfolioIndex = portfolioIndex, - isBalanceHidden = isBalanceHidden, - ) - itemsIndexed( - items = tokens, - key = { _, item -> item.id.toString() + "-portfolio-${portfolio.id}" }, - contentType = { _, item -> item::class.java }, - itemContent = { tokenIndex, token -> - val indexWithHeader = tokenIndex.inc() - SlideInItemVisibility( - currentIndex = tokenIndex, - lastIndex = lastIndex, - modifier = modifier - .testModifier(indexWithHeader) - .animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null) - .roundedShapeItemDecoration( - radius = TangemTheme.dimens.radius14, - currentIndex = indexWithHeader, - lastIndex = lastIndex, - backgroundColor = TangemTheme.colors.background.primary, - ), - visible = isExpanded, - ) { - val innerModifier = if (indexWithHeader == lastIndex) Modifier.padding(bottom = 8.dp) else Modifier - PortfolioTokensListItem( - state = token, - isBalanceHidden = isBalanceHidden, - modifier = innerModifier, - ) - } - }, - ) -} - -@Suppress("MagicNumber") -private fun LazyListScope.portfolioItem( - portfolio: TokensListItemUM.Portfolio, - modifier: Modifier, - portfolioIndex: Int, - isBalanceHidden: Boolean, -) { - val tokens = portfolio.tokens - val isExpanded = portfolio.isExpanded - val lastIndex = when { - isExpanded && tokens.isEmpty() -> 1 - isExpanded -> tokens.lastIndex.inc() - else -> 0 - } - - item( - key = "account-${portfolio.id}", - contentType = "account-content", - ) { - // Snap immediately on expand; on collapse, hold until all child items finish - // their shrink animation, then snap to fully-rounded shape. - val effectiveLastIndex by animateIntAsState( - targetValue = lastIndex, - animationSpec = if (lastIndex != 0) { - snap() - } else { - snap(delayMillis = minOf(50 * tokens.lastIndex, 250) + 150) - }, - label = "lastIndex", - ) - - PortfolioListItem( - state = portfolio, - isBalanceHidden = isBalanceHidden, - modifier = modifier - .testModifier(portfolioIndex) - .roundedShapeItemDecoration( - currentIndex = 0, - radius = TangemTheme.dimens.radius14, - lastIndex = effectiveLastIndex, - backgroundColor = TangemTheme.colors.background.primary, - ), - ) - } -} - -private fun Modifier.testModifier(index: Int): Modifier = this - .testTag(MainScreenTestTags.TOKEN_LIST_ITEM) - .semantics { lazyListItemPosition = index } - -@Suppress("MagicNumber") -@Composable -internal fun SlideInItemVisibility( - visible: Boolean, - currentIndex: Int, - lastIndex: Int, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val maxDelay = 250 - val delayEnter = minOf(50 * currentIndex, maxDelay) - val delayExit = minOf(50 * (lastIndex - currentIndex - 1), maxDelay) - - AnimatedVisibility( - modifier = modifier, - visible = visible, - enter = expandVertically( - tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing), - expandFrom = Alignment.Top, - ) + fadeIn(tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing)), - exit = shrinkVertically( - tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing), - shrinkTowards = Alignment.Top, - ) + fadeOut(tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing)), - ) { - content() - } -} - -// region Preview -@Composable -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun SwapSelectTokenScreen_Preview( - @PreviewParameter(SwapSelectTokenScreenPreviewProvider::class) params: SwapSelectTokenStateHolder, -) { - TangemThemePreview { - SwapSelectTokenScreen( - state = params, - onBack = {}, - ) - } -} - -private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - // Content state with tokens and markets - SwapSelectTokenPreviewProvider.defaultState, - // Empty state - SwapSelectTokenStateHolder( - tokensListData = TokenListUMData.EmptyList, - marketsState = SwapMarketState.DefaultLoading, - isAfterSearch = false, - isBalanceHidden = false, - onSearchEntered = {}, - ), - // Not found state - SwapSelectTokenStateHolder( - tokensListData = TokenListUMData.EmptyList, - marketsState = SwapMarketState.SearchLoading, - isAfterSearch = true, - isBalanceHidden = false, - onSearchEntered = {}, - ), - ) -} -// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index 9caf68f4c0..55f583d61e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -73,7 +73,7 @@ private fun SwapSuccessScreenContent( .padding(horizontal = TangemTheme.dimens.spacing16), ) { TransactionDoneTitle( - title = resourceReference(R.string.common_in_progress), + title = resourceReference(R.string.swap_in_progress), subtitle = resourceReference( R.string.send_date_format, wrappedList( @@ -190,7 +190,7 @@ private fun SwapSuccessScreenButtons( if (shouldShowStatusButton) { SpacerW12() SecondaryButtonIconStart( - text = stringResourceSafe(id = R.string.express_cex_status_button_title), + text = stringResourceSafe(id = R.string.express_provider), iconResId = R.drawable.ic_arrow_top_right_24, onClick = onStatusClick, modifier = Modifier.weight(1f), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 1886f3effd..0b5497b495 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -1,84 +1,99 @@ package com.tangem.feature.swap.ui -import androidx.annotation.DrawableRes +import android.content.res.Configuration import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.ripple -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.AccountTitle -import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.core.ui.R import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags -import com.tangem.core.ui.utils.ImageBackgroundContrastChecker -import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.models.SwapCardState import com.tangem.feature.swap.models.TransactionCardType -import kotlinx.coroutines.launch +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview -@Suppress("LongParameterList") @Composable -fun TransactionCard( - type: TransactionCardType, - balance: String, - tokenIconUrl: String, - tokenCurrency: String, - amountEquivalent: TextReference?, +internal fun TransactionCard( priceImpact: PriceImpact, - textFieldValue: TextFieldValue?, + swapCardState: SwapCardState, + onSelectTokenClick: () -> Unit, modifier: Modifier = Modifier, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, - onChangeTokenClick: (() -> Unit)? = null, ) { - val cardTag = when (type) { + val cardTag = when (swapCardState.type) { is TransactionCardType.Inputtable -> SwapTokenScreenTestTags.SWAP_CARD is TransactionCardType.ReadOnly -> SwapTokenScreenTestTags.RECEIVE_CARD } + when (swapCardState) { + is SwapCardState.Empty -> { + TransactionCardEmpty( + cardState = swapCardState, + onChangeTokenClick = onSelectTokenClick, + modifier = modifier.testTag(cardTag), + ) + } + is SwapCardState.SwapCardData -> { + TransactionCardData( + cardState = swapCardState, + priceImpact = priceImpact, + onChangeTokenClick = onSelectTokenClick, + modifier = modifier.testTag(cardTag), + ) + } + is SwapCardState.Loading -> TransactionCardLoading( + modifier = modifier.testTag(cardTag), + ) + } +} + +@Composable +private fun TransactionCardData( + cardState: SwapCardState.SwapCardData, + priceImpact: PriceImpact, + modifier: Modifier = Modifier, + onChangeTokenClick: (() -> Unit)? = null, +) { Box( modifier = modifier .background( shape = RoundedCornerShape(TangemTheme.dimens.radius16), color = TangemTheme.colors.background.primary, ) - .fillMaxSize() - .testTag(cardTag), + .fillMaxWidth(), ) { Column( modifier = Modifier @@ -86,22 +101,26 @@ fun TransactionCard( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start, ) { - Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type) + Header( + balance = stringResourceSafe( + R.string.common_balance, + cardState.balance, + ).orMaskWithStars(cardState.isBalanceHidden), + type = cardState.type, + ) Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, + type = cardState.type, + amountEquivalent = cardState.amountEquivalent, + textFieldValue = cardState.amountTextFieldValue, priceImpact = priceImpact, ) } Box(modifier = Modifier.align(Alignment.BottomEnd)) { Token( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - networkIconRes = networkIconRes, - iconPlaceholder = iconPlaceholder, + currencyIconState = cardState.currencyIconState, + tokenSymbol = cardState.tokenSymbol, ) } @@ -124,61 +143,134 @@ fun TransactionCard( } @Composable -fun TransactionCardEmpty( - type: TransactionCardType, - amountEquivalent: TextReference?, - textFieldValue: TextFieldValue?, +private fun TransactionCardEmpty( + cardState: SwapCardState.Empty, modifier: Modifier = Modifier, - onChangeTokenClick: (() -> Unit)? = null, + onChangeTokenClick: () -> Unit, ) { - Box( + Column( modifier = modifier .background( shape = RoundedCornerShape(TangemTheme.dimens.radius12), color = TangemTheme.colors.background.primary, ) - .fillMaxSize(), + .padding( + top = 12.dp, + start = 12.dp, + end = 12.dp, + bottom = 16.dp, + ) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, + AccountTitle( + accountTitleUM = cardState.type.accountTitleUM, + modifier = Modifier.fillMaxWidth(), + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Header( - balance = stringResourceSafe(id = R.string.swapping_token_not_available), - type = type, - ) - - Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, - priceImpact = PriceImpact.Empty, - ) - } - - Box(modifier = Modifier.align(Alignment.BottomEnd)) { - Token( - tokenIconUrl = "", - tokenCurrency = "", - iconPlaceholder = R.drawable.ic_no_token_44, - ) - } - - if (onChangeTokenClick != null) { - Box(modifier = Modifier.align(Alignment.CenterEnd)) { - ChangeTokenSelector() + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = cardState.amountTextFieldValue?.text.orEmpty(), + color = TangemTheme.colors.text.disabled, + style = TangemTheme.typography.h2, + autoSize = TextAutoSize.StepBased( + minFontSize = 16.sp, + maxFontSize = TangemTheme.typography.h2.fontSize, + ), + maxLines = 1, + modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), + ) + Text( + text = cardState.amountEquivalent.resolveAnnotatedReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + modifier = Modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size20) + .testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT), + ) } - Box( - Modifier - .align(Alignment.CenterEnd) - .height(TangemTheme.dimens.size116) - .width(TangemTheme.dimens.size102) - .clickable( - indication = ripple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - ) { onChangeTokenClick() }, + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.common_choose_token), + icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24), + onClick = onChangeTokenClick, + ), + ) + } + } +} + +@Composable +private fun TransactionCardLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius12), + color = TangemTheme.colors.background.primary, + ) + .padding( + top = 12.dp, + start = 12.dp, + end = 12.dp, + bottom = 16.dp, + ) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + TextShimmer( + text = stringResourceSafe(R.string.swapping_to_title), + style = TangemTheme.typography.subtitle2, + ) + TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier + .testTag(SwapTokenScreenTestTags.BALANCE) + .width(60.dp), + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextShimmer( + style = TangemTheme.typography.h2, + modifier = Modifier + .width(100.dp) + .testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), + ) + TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier + .defaultMinSize( + minHeight = 20.dp, + minWidth = 40.dp, + ) + .testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT), + ) + } + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.common_choose_token), + icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24), + isEnabled = false, + onClick = {}, + ), ) } } @@ -376,12 +468,7 @@ private fun Content( @Suppress("MagicNumber") @Composable -fun Token( - tokenIconUrl: String, - tokenCurrency: String, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, -) { +fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) { Column( modifier = Modifier .padding( @@ -392,15 +479,13 @@ fun Token( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.End, ) { - TokenIcon( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - iconPlaceholder = iconPlaceholder, - networkIconRes = networkIconRes, + CurrencyIcon( + state = currencyIconState, + modifier = Modifier.padding(end = TangemTheme.dimens.spacing16), ) SpacerH4() Text( - text = tokenCurrency, + text = tokenSymbol.resolveReference(), color = TangemTheme.colors.text.primary1, maxLines = 1, style = TangemTheme.typography.subtitle2, @@ -412,89 +497,10 @@ fun Token( } } -@Suppress("NullableToStringCall") -@Composable -private fun TokenIcon( - tokenIconUrl: String, - tokenCurrency: String, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, -) { - var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) } - var isBackgroundColorDefined by remember { mutableStateOf(false) } - val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb() - val isDarkTheme = isSystemInDarkTheme() - val coroutineScope = rememberCoroutineScope() - - Box( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing16) - .size(TangemTheme.dimens.size42) - .testTag(SwapTokenScreenTestTags.TOKEN_ICON), - ) { - val tokenImageModifier = Modifier - .align(Alignment.BottomStart) - .size(TangemTheme.dimens.size36) - .background( - color = iconBackgroundColor, - shape = TangemTheme.shapes.roundedCorners8, - ) - .clip(TangemTheme.shapes.roundedCorners8) - - val data = tokenIconUrl.ifEmpty { iconPlaceholder } - - val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() } - - SubcomposeAsyncImage( - modifier = tokenImageModifier, - model = ImageRequest.Builder(LocalContext.current) - .data(data) - .size(size = pixelsSize) - .memoryCacheKey(key = data.toString() + pixelsSize) - .crossfade(true) - .allowHardware(false) - .listener( - onSuccess = { _, result -> - if (isDarkTheme) { - coroutineScope.launch { - val color = ImageBackgroundContrastChecker( - drawable = result.drawable, - backgroundColor = itemBackgroundColor, - size = pixelsSize, - ).getContrastColor(true) - iconBackgroundColor = color - isBackgroundColorDefined = true - } - } - }, - ).build(), - loading = { CircleShimmer(modifier = tokenImageModifier) }, - contentDescription = tokenCurrency, - ) - - if (networkIconRes != null) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .size(TangemTheme.dimens.size18) - .background(color = TangemTheme.colors.background.primary, shape = CircleShape), - contentAlignment = Alignment.Center, - ) { - Image( - modifier = Modifier.padding(all = TangemTheme.dimens.spacing2), - painter = painterResource(id = networkIconRes), - contentDescription = null, - ) - } - } - } -} - @Composable fun ChangeTokenSelector() { Box( modifier = Modifier - .fillMaxHeight() .padding( top = TangemTheme.dimens.spacing12, start = TangemTheme.dimens.spacing24, @@ -513,129 +519,29 @@ fun ChangeTokenSelector() { } } -// region preview - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) +// region Preview @Composable -private fun Preview_TransactionCard_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreview() +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TransactionCard_Preview(@PreviewParameter(PreviewProvider::class) params: SwapCardState) { + TangemThemePreview { + TransactionCard( + priceImpact = PriceImpact.Empty, + swapCardState = params, + onSelectTokenClick = {}, + modifier = Modifier, + ) } } -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithPriceImpact_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithPriceImpact() - } +private class PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SwapTransactionCardPreview.sendCard, + SwapTransactionCardPreview.receiveCard, + SwapTransactionCardPreview.emptyReadOnlyCard, + SwapTransactionCardPreview.emptyInputtableCard, + SwapTransactionCardPreview.loadingCard, + ) } - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithoutPriceImpact_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithoutPriceImpact() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCard_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreview() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithPriceImpact_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithPriceImpact() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithoutPriceImpact() - } -} - -@Composable -private fun TransactionCardPreview() { - TransactionCard( - type = TransactionCardType.Inputtable( - onAmountChanged = {}, - onFocusChanged = {}, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue(), - priceImpact = PriceImpact.Empty, - ) -} - -@Composable -@Suppress("MagicNumber") -private fun TransactionCardPreviewWithPriceImpact() { - TransactionCard( - type = TransactionCardType.ReadOnly( - shouldShowWarning = true, - accountTitleUM = AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = AccountNameUM.DefaultMain.value, - icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), - ), - ), - amountEquivalent = combinedReference( - stringReference("1 000 000 $"), - styledStringReference( - " (-15%)", - { SpanStyle(color = TangemTheme.colors.text.attention) }, - ), - ), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue("1000000.0000000000000000000000000"), - priceImpact = PriceImpact( - value = 0.15F.toBigDecimal(), - type = PriceImpact.Type.MEDIUM, - amountSignificance = PriceImpact.AmountSignificance.HIGH, - ), - ) -} - -@Composable -@Suppress("MagicNumber") -private fun TransactionCardPreviewWithoutPriceImpact() { - TransactionCard( - type = TransactionCardType.ReadOnly( - accountTitleUM = AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = AccountNameUM.DefaultMain.value, - icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), - ), - ), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue(), - priceImpact = PriceImpact.Empty, - ) -} - -// endregion preview \ No newline at end of file +// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt deleted file mode 100644 index bb19ee30a7..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.tangem.feature.swap.ui.preview - -import com.tangem.common.ui.charts.state.MarketChartRawData -import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.core.ui.R -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.components.token.AccountItemPreviewData -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM -import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData -import com.tangem.feature.swap.models.market.state.SwapMarketState -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList -import java.math.BigDecimal - -internal object SwapSelectTokenPreviewProvider { - - private const val CHART_VALUE_1 = 0.4 - private const val CHART_VALUE_2 = 0.2 - private const val CHART_VALUE_3 = 0.1 - private const val CHART_VALUE_4 = 2.0 - private const val CHART_VALUE_5 = 5.0 - private const val CHART_VALUE_6 = 3.0 - private const val TOTAL_ITEMS = 322 - - private val PREVIEW_CHART_DATA = MarketChartRawData( - y = persistentListOf( - CHART_VALUE_1, - CHART_VALUE_2, - CHART_VALUE_1, - CHART_VALUE_3, - CHART_VALUE_1, - CHART_VALUE_4, - CHART_VALUE_5, - CHART_VALUE_3, - CHART_VALUE_4, - CHART_VALUE_4, - CHART_VALUE_6, - ), - ) - - private val tokenItemState = TokenItemState.Content( - id = "1", - iconState = CurrencyIconState.Locked, - titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "0,35853044 BTC"), - subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( - price = "34 496,75 \$", - priceChangePercent = "0,43 %", - type = PriceChangeType.DOWN, - ), - onItemClick = {}, - onItemLongClick = {}, - ) - - private val textContentTokensState = persistentListOf( - TokensListItemUM.GroupTitle(id = 111, text = stringReference("Network Bitcoin")), - TokensListItemUM.Token(state = tokenItemState), - TokensListItemUM.GroupTitle(id = 222, text = stringReference("Network Ethereum")), - TokensListItemUM.Token( - state = tokenItemState.copy( - id = "2", - titleState = TokenItemState.TitleState.Content(text = stringReference("Ethereum")), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "3 340,79 \$"), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "1,856660295 ETH"), - subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( - price = "1 799,41 \$", - priceChangePercent = "5,16 %", - type = PriceChangeType.UP, - ), - ), - ), - TokensListItemUM.Token( - state = TokenItemState.Unreachable( - id = "3", - iconState = CurrencyIconState.Locked, - titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")), - onItemClick = {}, - onItemLongClick = {}, - ), - ), - TokensListItemUM.Token( - state = tokenItemState.copy( - id = "4", - titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Shiba Inu")), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "48,64 \$"), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "6 200 220,00 SHIB"), - subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( - price = "0.01 \$", - priceChangePercent = "1,34 %", - type = PriceChangeType.DOWN, - ), - ), - ), - ) - - private val marketState = SwapMarketState.Content( - items = createPreviewMarketItems(), - loadMore = { }, - onItemClick = { }, - visibleIdsChanged = { }, - total = TOTAL_ITEMS, - marketsTitle = TextReference.Res(R.string.feed_trending_now), - shouldAssetsCount = false, - ) - - val defaultState = SwapSelectTokenStateHolder( - tokensListData = TokenListUMData.AccountList( - tokensList = persistentListOf( - TokensListItemUM.Portfolio( - content = PortfolioItemContentUM.Tokens( - tokens = textContentTokensState.filterIsInstance() - .toPersistentList(), - ), - isExpanded = false, - isCollapsable = true, - tokenItemUM = AccountItemPreviewData.accountItem - .copy(iconState = AccountItemPreviewData.accountLetterIcon), - ), - TokensListItemUM.Portfolio( - content = PortfolioItemContentUM.Tokens( - tokens = textContentTokensState.filterIsInstance() - .toPersistentList(), - ), - isExpanded = true, - isCollapsable = true, - tokenItemUM = AccountItemPreviewData.accountItem, - ), - ), - totalTokensCount = TOTAL_ITEMS, - ), - isAfterSearch = false, - isBalanceHidden = false, - onSearchEntered = {}, - marketsState = marketState, - ) - - private fun createPreviewMarketItems() = listOf( - createMarketItem( - id = "1", - iconUrl = "", - ratingPosition = "10", - marketCap = "$6.233 B", - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "2", - ratingPosition = "10", - marketCap = "$6.233 B", - trendType = PriceChangeType.NEUTRAL, - chartData = null, - ), - createMarketItem( - id = "3", - name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", - ratingPosition = "10", - marketCap = "$6.23348172384781234 B", - trendType = PriceChangeType.DOWN, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "4", - ratingPosition = "10", - marketCap = null, - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "5", - ratingPosition = null, - marketCap = "$6.233 B", - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "6", - ratingPosition = null, - marketCap = null, - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - ).toImmutableList() - - private fun createMarketItem( - id: String, - name: String = "Bitcoin", - iconUrl: String? = null, - ratingPosition: String?, - marketCap: String?, - trendType: PriceChangeType, - chartData: MarketChartRawData?, - ) = MarketsListItemUM( - id = CryptoCurrency.RawID(id), - name = name, - currencySymbol = "BTC", - iconUrl = iconUrl, - ratingPosition = ratingPosition, - marketCap = marketCap, - price = MarketsListItemUM.Price( - text = "31 285.72$", - annotated = stringReference("31 285.72$"), - fiatPrice = BigDecimal("123123"), - ), - trendPercentText = "12.43%", - trendType = trendType, - chartData = chartData, - isUnder100kMarketCap = false, - stakingRate = stringReference("APY 12.34%"), - updateTimestamp = 0, - ) -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt new file mode 100644 index 0000000000..04fc89a1a1 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt @@ -0,0 +1,79 @@ +package com.tangem.feature.swap.ui.preview + +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.CryptoPortfolioIconConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.feature.swap.models.SwapCardState +import com.tangem.feature.swap.models.TransactionCardType +import com.tangem.feature.swap.presentation.R + +internal object SwapTransactionCardPreview { + + val sendCard = SwapCardState.SwapCardData( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Account( + prefixText = stringReference("From"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), + ), + ), + amountTextFieldValue = TextFieldValue(), + amountEquivalent = stringReference("1 000 000"), + currencyIconState = CurrencyIconState.Loading, + tokenSymbol = stringReference("DAI"), + balance = "123", + isBalanceHidden = false, + ) + + val receiveCard = SwapCardState.SwapCardData( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Account( + prefixText = stringReference("To"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), + ), + ), + amountTextFieldValue = TextFieldValue(), + amountEquivalent = stringReference("1 000 000"), + currencyIconState = CurrencyIconState.Loading, + tokenSymbol = stringReference("DAI"), + balance = "33333", + isBalanceHidden = false, + ) + + val emptyReadOnlyCard = SwapCardState.Empty( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + ), + amountEquivalent = stringReference("$0.00"), + amountTextFieldValue = null, + ) + + val emptyInputtableCard = SwapCardState.Empty( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)), + ), + amountEquivalent = stringReference("$0.00"), + amountTextFieldValue = null, + ) + + val loadingCard = SwapCardState.Loading( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + ), + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt new file mode 100644 index 0000000000..82ce65c84e --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt @@ -0,0 +1,867 @@ +package com.tangem.feature.swap + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.feature.swap.model.InitialCurrenciesResolver +import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultInitialCurrenciesResolverTest { + + private val getUserWalletUseCase = mockk() + private val singleAccountStatusListSupplier = mockk() + private val rampStateManager = mockk() + + private val userWalletId = UserWalletId("0011") + private val userWallet = mockk { + every { walletId } returns userWalletId + } + + private val resolver = InitialCurrenciesResolver( + getUserWalletUseCase = getUserWalletUseCase, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + rampStateManager = rampStateManager, + ) + + private var uniqueIndex = 0 + + @BeforeEach + fun setup() { + coEvery { getUserWalletUseCase(userWalletId) } returns userWallet.right() + } + + // region no initial currency + + @Test + fun `GIVEN available tokens with balance WHEN no initial currency THEN returns available token with max fiat balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + val currency3 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("300")) + val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("500")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2, status3)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to true, currency2 to true, currency3 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status2) + assertThat(to).isNull() + } + + @Test + fun `GIVEN available tokens without balance WHEN no initial currency THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to true, currency2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN no available tokens with balance WHEN no initial currency THEN returns token with max fiat balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to false, currency2 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status2) + assertThat(to).isNull() + } + + @Test + fun `GIVEN no available tokens without balance WHEN no initial currency THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to false, currency2 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN empty accounts WHEN no initial currency THEN returns null pair`() = runTest { + setupSupplier(emptyList()) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to).isNull() + } + + @Test + fun `GIVEN multiple accounts WHEN fallback to first THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = null) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val account1Status = createCryptoPortfolioAccountStatus(listOf(status1)) + val account2Status = createCryptoPortfolioAccountStatus(listOf(status2)) + setupSupplier(listOf(account1Status, account2Status)) + + setupAvailability(linkedMapOf(currency1 to true)) + setupAvailability(linkedMapOf(currency2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN mixed availability and balance across accounts WHEN no initial currency THEN returns best available with balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + val currency3 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("50")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200")) + val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("100")) + + val account1Status = createCryptoPortfolioAccountStatus(listOf(status1)) + val account2Status = createCryptoPortfolioAccountStatus(listOf(status2, status3)) + setupSupplier(listOf(account1Status, account2Status)) + + setupAvailability(linkedMapOf(currency1 to true)) + setupAvailability(linkedMapOf(currency2 to false, currency3 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status3) + assertThat(to).isNull() + } + + // endregion + + // region initial currency tests + + @Test + fun `GIVEN initial currency not found WHEN invoke THEN returns null pair`() = runTest { + val initialCurrency = mockCryptoCurrency() + val otherCurrency = mockCryptoCurrency() + + val status = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency available with balance WHEN invoke THEN returns it as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency available without balance WHEN invoke THEN returns it as to and best as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to true, otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency available without balance and is only token WHEN invoke THEN returns it as to and from is null`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN initial currency not available with balance WHEN invoke THEN returns it as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency not available with balance and other available with higher balance WHEN invoke THEN returns initial as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(initialStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency not available without balance and other available with balance WHEN invoke THEN returns best available as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val other1 = mockCryptoCurrency() + val other2 = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val other1Status = createCurrencyStatus(other1, fiatAmount = BigDecimal("100")) + val other2Status = createCurrencyStatus(other2, fiatAmount = BigDecimal("300")) + val accountStatus = createCryptoPortfolioAccountStatus( + listOf(initialStatus, other1Status, other2Status), + ) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, other1 to true, other2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(other2Status) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and other available without balance WHEN invoke THEN returns first token as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to true, accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and no available tokens with balance WHEN invoke THEN returns best by balance as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and no available tokens without balance WHEN invoke THEN returns first token as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to false, accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and is only token WHEN invoke THEN returns it as to and from is null`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN initial from second account without balance and same token in main with balance WHEN invoke THEN does not pick same token as from`() = + runTest { + val sharedNetworkId = "ethereum" + val sharedContractAddress = "0xUSDT" + + // Same token (same network + contract), different ids (simulates different derivations) + val idInSecondary = mockCurrencyId(sharedNetworkId, sharedContractAddress) + val idInMain = mockCurrencyId(sharedNetworkId, sharedContractAddress) + val initialCurrency = mockCryptoCurrency(id = idInSecondary) + val usdtInSecondary = mockCryptoCurrency(id = idInSecondary) + val usdtInMain = mockCryptoCurrency(id = idInMain) + + val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = null) + val statusInMain = createCurrencyStatus(usdtInMain, fiatAmount = BigDecimal("1000")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(statusInMain, statusInSecondary)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(usdtInMain to true, usdtInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Selected goes to TO; FROM must not be the same token from another account + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(statusInSecondary) + } + + @Test + fun `GIVEN initial from second account with balance WHEN invoke THEN returns it as from`() = runTest { + val idInSecondary = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = idInSecondary) + val usdtInSecondary = mockCryptoCurrency(id = idInSecondary) + val otherCurrency = mockCryptoCurrency() + + val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = BigDecimal("200")) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, statusInSecondary)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to true, usdtInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(statusInSecondary) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial in secondary account placed in TO WHEN invoke THEN FROM is picked only from same account`() = + runTest { + // Main account has a high-balance available currency. + val mainOnlyCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("10000")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account holds the initial currency (available, zero balance → TO) + // plus another available currency with balance. + val initialId = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInSecondary = mockCryptoCurrency(id = initialId) + val secondaryCompanion = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO) + val secondaryStatus = createCurrencyStatus(secondaryCompanion, fiatAmount = BigDecimal("50")) + + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, secondaryStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + + setupAvailability(linkedMapOf(mainOnlyCurrency to true)) + setupAvailability(linkedMapOf(initialInSecondary to true, secondaryCompanion to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must come from secondary account only — never the main account's high-balance currency. + assertThat(from?.status).isSameInstanceAs(secondaryStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial in secondary account is only token in that account WHEN invoke THEN FROM is null`() = + runTest { + // Main account has candidates that must NOT be picked as FROM. + val mainOnlyCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("500")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account has only the initial currency (available, no balance → TO). + val initialId = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInSecondary = mockCryptoCurrency(id = initialId) + + val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO) + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + + setupAvailability(linkedMapOf(mainOnlyCurrency to true)) + setupAvailability(linkedMapOf(initialInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Secondary account has no other candidates; FROM must be null, not pulled from main. + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + // endregion + + // region currency position FROM + + @Test + fun `GIVEN position FROM and available with balance WHEN invoke THEN returns selected as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.FROM, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN position FROM and not available without balance WHEN invoke THEN still returns selected as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.FROM, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + // endregion + + // region currency position TO + + @Test + fun `GIVEN position TO and available with balance WHEN invoke THEN returns selected as to`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.TO, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN position TO and not available without balance WHEN invoke THEN still returns selected as to`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.TO, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + // endregion + + // region helpers + + private fun mockCryptoCurrency( + id: CryptoCurrency.ID = mockCurrencyId(), + ): CryptoCurrency = mockk(relaxed = true) { + every { this@mockk.id } returns id + } + + private fun mockCurrencyId( + rawNetworkId: String = "net-${uniqueIndex++}", + contractAddress: String = "contract-${uniqueIndex++}", + ): CryptoCurrency.ID = mockk(relaxed = true) { + every { this@mockk.rawNetworkId } returns rawNetworkId + every { this@mockk.contractAddress } returns contractAddress + } + + private fun createCurrencyStatus( + currency: CryptoCurrency, + fiatAmount: BigDecimal?, + ): CryptoCurrencyStatus { + val value = mockk { + every { this@mockk.fiatAmount } returns fiatAmount + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + private fun createCryptoPortfolioAccountStatus( + currencies: List, + ): AccountStatus.CryptoPortfolio { + val account = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) + return AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortedBy = TokensSortType.NONE, + currencies = currencies, + ), + priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)), + ) + } + + private fun createCryptoPortfolioAccountStatus( + currencies: List, + derivationIndexValue: Int, + ): AccountStatus.CryptoPortfolio { + val derivationIndex = requireNotNull(DerivationIndex(value = derivationIndexValue).getOrNull()) { + "Invalid derivation index for test: $derivationIndexValue" + } + val accountId = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = derivationIndex, + ) + val accountName = if (derivationIndex.isMain) { + AccountName.DefaultMain + } else { + requireNotNull(AccountName.Custom(value = "Account $derivationIndexValue").getOrNull()) { + "Invalid account name for test" + } + } + val account = Account.CryptoPortfolio( + accountId = accountId, + accountName = accountName, + icon = CryptoPortfolioIcon.ofMainAccount(userWalletId), + derivationIndex = derivationIndex, + ) + return AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortedBy = TokensSortType.NONE, + currencies = currencies, + ), + priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)), + ) + } + + private fun setupSupplier(accountStatuses: List) { + val accountStatusList = if (accountStatuses.isEmpty()) { + null + } else { + AccountStatusList( + userWalletId = userWalletId, + accountStatuses = accountStatuses, + totalAccounts = accountStatuses.size, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + } + coEvery { + singleAccountStatusListSupplier.getSyncOrNull(any(), any()) + } returns accountStatusList + } + + private fun setupAvailability(currenciesAvailability: LinkedHashMap) { + val result = currenciesAvailability.map { (currency, available) -> + val reason = if (available) { + ScenarioUnavailabilityReason.None + } else { + ScenarioUnavailabilityReason.Unreachable + } + currency to reason + }.toMap() + coEvery { rampStateManager.availableForSwap(userWalletId, currenciesAvailability.keys.toList()) } returns result + } + + // endregion +} \ No newline at end of file diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt index 0f2ae969eb..aca7d9b879 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt @@ -1,5 +1,3 @@ package com.tangem.features.tangempay -interface TangemPayFeatureToggles { - val isTangemPayAccountsRefactorEnabled: Boolean -} \ No newline at end of file +interface TangemPayFeatureToggles \ No newline at end of file diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 0aee66703c..84b5260423 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -61,4 +61,15 @@ dependencies { /** Other */ implementation(deps.kotlin.immutable.collections) + + /** Test */ + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) +} + +tasks.withType().configureEach { + useJUnitPlatform() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt index a46d768445..e8a0c42caf 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt @@ -1,11 +1,3 @@ package com.tangem.features.tangempay -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager - -internal class DefaultTangemPayFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : TangemPayFeatureToggles { - override val isTangemPayAccountsRefactorEnabled - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED) -} \ No newline at end of file +internal class DefaultTangemPayFeatureToggles : TangemPayFeatureToggles \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index baa27a447b..52b0069572 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -16,7 +16,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -25,13 +25,14 @@ import dagger.assisted.AssistedInject internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsContainerComponent.Params, + private val tangemPayCardPageFactory: TangemPayCardPageComponent.Factory, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val expressTransactionsComponentProvider: ExpressTransactionsComponentProvider, ) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { - private val stackNavigation = StackNavigation() + private val stackNavigation = StackNavigation() - private val innerRouter = InnerRouter( + private val innerRouter = InnerRouter( stackNavigation = stackNavigation, popCallback = { onChildBack() }, ) @@ -39,8 +40,8 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru private val childStack = childStack( key = "tangemPayDetailsInnerStack", source = stackNavigation, - serializer = TangemPayDetailsInnerRoute.serializer(), - initialConfiguration = TangemPayDetailsInnerRoute.Details, + serializer = TangemPayAccountDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayAccountDetailsInnerRoute.AccountDetails, childFactory = ::screenChild, ) @@ -55,25 +56,18 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru } private fun screenChild( - config: TangemPayDetailsInnerRoute, + config: TangemPayAccountDetailsInnerRoute, componentContext: ComponentContext, ): ComposableContentComponent = when (config) { - TangemPayDetailsInnerRoute.Details -> TangemPayDetailsComponent( + TangemPayAccountDetailsInnerRoute.AccountDetails -> TangemPayDetailsComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, expressTransactionsComponentProvider = expressTransactionsComponentProvider, ) - TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = params, - ) - TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - ) - TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = params, + TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( + context = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayCardPageComponent.Params(userWalletId = params.userWalletId, config = params.config), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt index 561ef1f9cf..91d394396e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt @@ -23,7 +23,10 @@ internal class TangemPayAddToWalletComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params(params = params), + params = TangemPayCardDetailsBlockComponent.Params( + params = params, + isDisplayCardNameEnabled = false, + ), ) @Composable 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 new file mode 100644 index 0000000000..6d2e0dc50b --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -0,0 +1,121 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupComponent +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessComponent +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class TangemPayCardPageComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val childStack = childStack( + key = "tangemPayCardPageInnerStack", + source = stackNavigation, + serializer = TangemPayCardDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayCardDetailsInnerRoute.Details, + childFactory = ::screenChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStack by childStack.subscribeAsState() + BackHandler(onBack = ::onChildBack) + Children( + modifier = modifier, + stack = childStack, + ) { child -> + child.instance.Content(modifier = Modifier.fillMaxSize()) + } + } + + private fun screenChild( + config: TangemPayCardDetailsInnerRoute, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + TangemPayCardDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + tokenReceiveComponentFactory = tokenReceiveComponentFactory, + ) + TangemPayCardDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayCardDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + TangemPayCardDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayCardDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayCardDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + } + + private fun onChildBack() { + if (childStack.value.backStack.isEmpty()) { + router.pop() + } else { + stackNavigation.pop() + } + } + + data class Params(val userWalletId: UserWalletId, val config: TangemPayDetailsConfig) + + @AssistedFactory + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): TangemPayCardPageComponent + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt new file mode 100644 index 0000000000..9d3e29f799 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -0,0 +1,113 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.entity.TangemPayCardNavigation +import com.tangem.features.tangempay.model.TangemPayCardPageModel +import com.tangem.features.tangempay.ui.TangemPayCardPageScreen +import com.tangem.features.tokenreceive.TokenReceiveComponent + +internal class TangemPayCardPageScreenComponent( + private val appComponentContext: AppComponentContext, + private val params: TangemPayCardPageComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayCardPageModel = getOrCreateModel(params = params) + + private val containerParams = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ) + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("cardDetailsBlockComponent"), + params = TangemPayCardDetailsBlockComponent.Params( + params = containerParams, + isDisplayCardNameEnabled = true, + ), + ) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TangemPayCardNavigation.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + NavigationBar3ButtonsScrim() + TangemPayCardPageScreen( + state = state, + cardDetailsBlockComponent = cardDetailsBlockComponent, + cardDetailsState = cardDetailsState.copy( + isActive = !state.isReissueInProgress, + ), + modifier = modifier, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + navigation: TangemPayCardNavigation, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val context = childByContext(componentContext) + return when (navigation) { + is TangemPayCardNavigation.ViewPinCode -> TangemPayViewPinComponent( + appComponentContext = context, + params = TangemPayViewPinComponent.Params( + walletId = navigation.userWalletId, + cardId = navigation.cardId, + listener = model, + ), + ) + is TangemPayCardNavigation.ReissueCard -> TangemPayReissueCardComponent( + appComponentContext = context, + params = TangemPayReissueCardComponent.Params( + listener = model, + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ), + ) + is TangemPayCardNavigation.AddFunds -> TangemPayAddFundsComponent( + appComponentContext = context, + params = TangemPayAddFundsComponent.Params( + listener = model, + walletId = navigation.walletId, + cryptoBalance = navigation.cryptoBalance, + fiatBalance = navigation.fiatBalance, + depositAddress = navigation.depositAddress, + chainId = navigation.chainId, + ), + ) + is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create( + context = context, + params = TokenReceiveComponent.Params( + config = navigation.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt index d47fcd6737..eccf167f98 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt @@ -2,10 +2,10 @@ package com.tangem.features.tangempay.components import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import androidx.compose.ui.Modifier import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.features.tangempay.ui.TangemPayChangePinCodeSuccessScreen internal class TangemPayChangePinSuccessComponent( @@ -19,6 +19,6 @@ internal class TangemPayChangePinSuccessComponent( } private fun backToDetails() { - router.popTo(route = TangemPayDetailsInnerRoute.Details) + router.popTo(route = TangemPayCardDetailsInnerRoute.Details) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 58f5d9ef84..806f7c1980 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -16,8 +16,6 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent @@ -49,11 +47,6 @@ internal class TangemPayDetailsComponent( ), ) - private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( - appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params(params = params), - ) - private val expressTransactionsComponent by lazy { expressTransactionsComponentProvider.create( appComponentContext = child("expressTransactionsComponent"), @@ -78,7 +71,6 @@ internal class TangemPayDetailsComponent( TangemPayDetailsScreen( state = state, txHistoryComponent = txHistoryComponent, - cardDetailsBlockComponent = cardDetailsBlockComponent, expressTransactionsComponent = expressTransactionsComponent, modifier = modifier, ) @@ -119,14 +111,6 @@ internal class TangemPayDetailsComponent( listener = model, ), ) - is TangemPayDetailsNavigation.ViewPinCode -> TangemPayViewPinComponent( - appComponentContext = context, - params = TangemPayViewPinComponent.Params( - walletId = navigation.userWalletId, - cardId = navigation.cardId, - listener = model, - ), - ) } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt new file mode 100644 index 0000000000..8b4bb6214d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt @@ -0,0 +1,51 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.entity.DisplayNameState +import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel +import com.tangem.features.tangempay.ui.TangemPayEditDisplayNameScreen + +internal class TangemPayEditDisplayNameComponent( + private val appComponentContext: AppComponentContext, + params: TangemPayDetailsContainerComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayEditDisplayNameModel = getOrCreateModel(params) + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("editDisplayNameCardDetails"), + params = TangemPayCardDetailsBlockComponent.Params(params = params, isDisplayCardNameEnabled = true), + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val editingCardDetailsState = cardDetailsState.copy( + displayNameState = DisplayNameState.Editing( + displayName = state.editingValue, + editingValue = state.editingValue, + onValueChanged = state.onValueChanged, + onSubmit = state.onDoneClick, + onDismiss = state.onDismiss, + ), + ) + BackHandler(onBack = state.onDismiss) + TangemPayEditDisplayNameScreen( + state = state, + cardDetailsBlockComponent = cardDetailsBlockComponent, + cardDetailsState = editingCardDetailsState, + modifier = modifier, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt new file mode 100644 index 0000000000..13f1c7c061 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.features.tangempay.model.TangemPayReissueCardModel +import com.tangem.features.tangempay.ui.TangemPayReissueCardContent + +internal class TangemPayReissueCardComponent( + appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayReissueCardModel = getOrCreateModel(params = params) + + override fun dismiss() = model.onDismiss() + + @Composable + override fun BottomSheet() { + val state by model.state.collectAsStateWithLifecycle() + TangemPayReissueCardContent(state = state) + } + + data class Params( + val listener: ReissueCardListener, + val userWalletId: UserWalletId, + val cardId: String, + ) +} + +internal interface ReissueCardListener { + fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) + fun onDismissReissueCard() + fun onClickAddFunds() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt index 9fb41015c0..9b80de3eb5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -14,5 +14,8 @@ internal interface TangemPayCardDetailsBlockComponent { @Composable fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) - data class Params(val params: TangemPayDetailsContainerComponent.Params) + data class Params( + val params: TangemPayDetailsContainerComponent.Params, + val isDisplayCardNameEnabled: Boolean, + ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt index a6ea142d28..6f0b9ea597 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt @@ -1,6 +1,5 @@ package com.tangem.features.tangempay.di -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.tangempay.DefaultTangemPayFeatureToggles import com.tangem.features.tangempay.TangemPayFeatureToggles import dagger.Module @@ -15,7 +14,7 @@ internal object TangemPayDetailsModule { @Provides @Singleton - fun provideTangemPayFeatureToggles(featureTogglesManager: FeatureTogglesManager): TangemPayFeatureToggles { - return DefaultTangemPayFeatureToggles(featureTogglesManager) + fun provideTangemPayFeatureToggles(): TangemPayFeatureToggles { + return DefaultTangemPayFeatureToggles() } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index 2150e54279..1766696a25 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -5,10 +5,14 @@ import com.tangem.core.decompose.model.Model import com.tangem.features.tangempay.model.TangemPayAddFundsModel import com.tangem.features.tangempay.model.TangemPayAddToWalletModel import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel +import com.tangem.features.tangempay.model.TangemPayCardPageModel +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel import com.tangem.features.tangempay.model.TangemPayChangePinModel import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel import com.tangem.features.tangempay.model.TangemPayTxHistoryModel +import com.tangem.features.tangempay.model.TangemPayReissueCardModel import com.tangem.features.tangempay.model.TangemPayViewPinModel import dagger.Binds import dagger.Module @@ -59,4 +63,24 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayViewPinModel::class) fun bindTangemPayViewPinModel(model: TangemPayViewPinModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayCardPageModel::class) + fun bindTangemPayCardPageModel(model: TangemPayCardPageModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayEditDisplayNameModel::class) + fun bindTangemPayEditDisplayNameModel(model: TangemPayEditDisplayNameModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayReissueCardModel::class) + fun bindTangemPayReissueCardModel(model: TangemPayReissueCardModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayCardLimitSetupModel::class) + fun bindTangemPayCardLimitSetupModel(model: TangemPayCardLimitSetupModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index e79a05f253..d57030a177 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -8,6 +8,7 @@ import com.tangem.utils.StringsSigns internal class TangemPayCardDetailsBlockStateFactory( private val cardNumberEnd: String, + private val displayNameState: DisplayNameState?, private val onReveal: () -> Unit, private val onCopy: (String, CardDataType) -> Unit, ) { @@ -22,5 +23,6 @@ internal class TangemPayCardDetailsBlockStateFactory( onCopy = onCopy, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = displayNameState, ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt new file mode 100644 index 0000000000..7e527748ee --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt @@ -0,0 +1,30 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.serialization.SerializedBigDecimal +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayCardNavigation { + @Serializable + data class ViewPinCode( + val userWalletId: UserWalletId, + val cardId: String, + ) : TangemPayCardNavigation() + + @Serializable + data object ReissueCard : TangemPayCardNavigation() + + @Serializable + data class AddFunds( + val walletId: UserWalletId, + val cryptoBalance: SerializedBigDecimal, + val fiatBalance: SerializedBigDecimal, + val depositAddress: String, + val chainId: Int, + ) : TangemPayCardNavigation() + + @Serializable + data class Receive(val config: TokenReceiveConfig) : TangemPayCardNavigation() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt new file mode 100644 index 0000000000..c70080b726 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -0,0 +1,41 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class TangemPayCardPageUM( + val settings: ImmutableList, + val onBackClick: () -> Unit, + val dailyLimitState: TangemPayDailyLimitBlockState, + val addToWalletBlockState: AddToWalletBlockState? = null, + val isReissueInProgress: Boolean = false, +) { + companion object { + fun stub( + addToWalletBlockState: AddToWalletBlockState? = AddToWalletBlockState(onClick = {}, onClickClose = {}), + settings: ImmutableList = persistentListOf( + TangemPayCardPageSetting(TextReference.Str("Pin Code")) {}, + TangemPayCardPageSetting(TextReference.Str("Freeze Card")) {}, + TangemPayCardPageSetting(TextReference.Str("Reissue Card")) {}, + ), + isReissueInProgress: Boolean = false, + dailyLimitState: TangemPayDailyLimitBlockState = TangemPayDailyLimitBlockState.Content.stub(), + ) = TangemPayCardPageUM( + addToWalletBlockState = addToWalletBlockState, + settings = settings, + onBackClick = {}, + isReissueInProgress = isReissueInProgress, + dailyLimitState = dailyLimitState, + ) + } +} + +@Immutable +internal data class TangemPayCardPageSetting( + val title: TextReference, + val testTag: String? = null, + val onSettingClick: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDailyLimitBlockState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDailyLimitBlockState.kt new file mode 100644 index 0000000000..841bad5227 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDailyLimitBlockState.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface TangemPayDailyLimitBlockState { + data object Loading : TangemPayDailyLimitBlockState + + data object Error : TangemPayDailyLimitBlockState + + data class Content( + val limit: String, + val onChangeClick: () -> Unit, + ) : TangemPayDailyLimitBlockState { + companion object { + fun stub() = Content( + limit = "$5,000", + onChangeClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt index 5f10a9ed77..86009f09f0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -26,10 +26,4 @@ internal sealed class TangemPayDetailsNavigation { val transaction: TangemPayTxHistoryItem, val isBalanceHidden: Boolean, ) : TangemPayDetailsNavigation() - - @Serializable - data class ViewPinCode( - val userWalletId: UserWalletId, - val cardId: String, - ) : TangemPayDetailsNavigation() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index eba5584f1c..ea86449252 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -1,21 +1,17 @@ package com.tangem.features.tangempay.entity -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.notifications.NotificationConfig -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.model.transformers.TangemPayCardFrozenStateConverter import com.tangem.features.tangempay.utils.TangemPayDetailIntents import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TangemPayDetailsStateFactory( @@ -23,15 +19,14 @@ internal class TangemPayDetailsStateFactory( private val onOpenMenu: () -> Unit, private val intents: TangemPayDetailIntents, private val cardFrozenState: TangemPayCardFrozenState, - private val converter: TangemPayCardFrozenStateConverter, ) { @Suppress("LongMethod") - fun getInitialState(isTangemPayDeactivated: Boolean): TangemPayDetailsUM { + fun getInitialState(isTangemPayDeactivated: Boolean, cardNumberEnd: String): TangemPayDetailsUM { return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, onOpenMenu = onOpenMenu, - items = getTopBarMenuItems().takeIf { !isTangemPayDeactivated }, + items = getTopBarMenuItems(isTangemPayDeactivated), ), pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, @@ -52,71 +47,40 @@ internal class TangemPayDetailsStateFactory( isEnabled = cardFrozenState == TangemPayCardFrozenState.Unfrozen, ), ), + cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState( + cards = persistentListOf( + TangemPayDetailsBalanceBlockState.Card( + lastDigits = cardNumberEnd, + onClick = intents::onCardClick, + ), + ), + onAddCardClick = intents::onAddCardClick, + ), ), - addToWalletBlockState = null, isBalanceHidden = false, addFundsEnabled = true, - cardFrozenState = getCardFrozenState().takeIf { !isTangemPayDeactivated }, - betaNotificationConfig = getBetaNotificationConfig().takeIf { !isTangemPayDeactivated }, accountDeactivatedNotificationConfig = NotificationConfig( - title = TextReference.Res(R.string.tangempay_account_deactivated_message_title), - subtitle = TextReference.Res(R.string.tangempay_account_deactivated_message_subtitle), + title = resourceReference(R.string.tangempay_account_deactivated_message_title), + subtitle = resourceReference(R.string.tangempay_account_deactivated_message_subtitle), iconResId = R.drawable.img_attention_20, ).takeIf { isTangemPayDeactivated }, ) } - private fun getCardFrozenState() = converter.convert(cardFrozenState) + private fun getTopBarMenuItems(isTangemPayDeactivated: Boolean): ImmutableList { + if (isTangemPayDeactivated) return persistentListOf() - private fun getBetaNotificationConfig() = NotificationConfig( - title = resourceReference(R.string.tangem_pay_beta_notification_title), - subtitle = resourceReference(R.string.tangem_pay_beta_notification_subtitle), - iconResId = R.drawable.img_visa_notification, - buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.common_contact_support), - onClick = intents::onContactSupportClicked, - ), - iconSize = 36.dp, - ) - - private fun getTopBarMenuItems(): ImmutableList { - val cardFrozenStateItem = when (cardFrozenState) { - is TangemPayCardFrozenState.Pending -> null - is TangemPayCardFrozenState.Frozen -> TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.UnfreezeCard, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangempay_card_details_unfreeze_card), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickUnfreezeCard, - ), - ) - is TangemPayCardFrozenState.Unfrozen -> TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.FreezeCard, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangempay_card_details_freeze_card), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickFreezeCard, - ), - ) - } - return listOfNotNull( - TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.ChangePin, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangempay_card_details_pin_code), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickPinCode, - ), + return persistentListOf( + TangemDropdownMenuItem( + title = resourceReference(R.string.tangem_pay_terms_limits), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onClickTermsAndLimits, ), - TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.TermsAndLimits, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangem_pay_terms_limits), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickTermsAndLimits, - ), + TangemDropdownMenuItem( + title = resourceReference(R.string.tangempay_pay_support), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onContactSupportClicked, ), - cardFrozenStateItem, - ).toPersistentList() + ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index bd4daa10c9..9675f1b31d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -6,17 +6,5 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, val onOpenMenu: () -> Unit, - val items: ImmutableList?, -) - -internal data class TangemPayDetailsTopBarMenuItem( - val type: TangemPayDetailsTopBarMenuItemType, - val dropdownItem: TangemDropdownMenuItem, -) - -internal enum class TangemPayDetailsTopBarMenuItemType { - ChangePin, - TermsAndLimits, - FreezeCard, - UnfreezeCard, -} \ No newline at end of file + val items: ImmutableList, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 000fd9d0b5..b6b591544f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -12,11 +12,8 @@ internal data class TangemPayDetailsUM( val topBarConfig: TangemPayDetailsTopBarConfig, val pullToRefreshConfig: PullToRefreshConfig, val balanceBlockState: TangemPayDetailsBalanceBlockState, - val addToWalletBlockState: AddToWalletBlockState?, val isBalanceHidden: Boolean, val addFundsEnabled: Boolean, - val cardFrozenState: CardFrozenState?, - val betaNotificationConfig: NotificationConfig?, val accountDeactivatedNotificationConfig: NotificationConfig?, ) @@ -31,31 +28,52 @@ internal data class TangemPayCardDetailsUM( val isHidden: Boolean = true, val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, + val displayNameState: DisplayNameState?, + val isActive: Boolean = true, ) +internal sealed interface DisplayNameState { + + val displayName: String + + data class Display( + override val displayName: String, + val onClick: () -> Unit, + ) : DisplayNameState + + data class Editing( + override val displayName: String, + val editingValue: String, + val onValueChanged: (String) -> Unit, + val onSubmit: () -> Unit, + val onDismiss: () -> Unit, + ) : DisplayNameState +} + internal sealed class TangemPayDetailsBalanceBlockState { abstract val actionButtons: ImmutableList + abstract val cardsBlockState: CardsBlockState data class Loading( override val actionButtons: ImmutableList, + override val cardsBlockState: CardsBlockState, ) : TangemPayDetailsBalanceBlockState() data class Content( override val actionButtons: ImmutableList, + override val cardsBlockState: CardsBlockState, val fiatBalance: String, val isBalanceFlickering: Boolean, ) : TangemPayDetailsBalanceBlockState() data class Error( override val actionButtons: ImmutableList, + override val cardsBlockState: CardsBlockState, ) : TangemPayDetailsBalanceBlockState() -} -sealed class CardFrozenState { - data object Pending : CardFrozenState() - data class Frozen(val onUnfreeze: () -> Unit) : CardFrozenState() - data object Unfrozen : CardFrozenState() + data class CardsBlockState(val cards: ImmutableList, val onAddCardClick: () -> Unit) + data class Card(val lastDigits: String, val onClick: () -> Unit) } internal data class AddToWalletBlockState( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt new file mode 100644 index 0000000000..a13fdbc1d0 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.tangempay.entity + +internal data class TangemPayEditDisplayNameUM( + val editingValue: String, + val isLoading: Boolean, + val onValueChanged: (String) -> Unit, + val onDoneClick: () -> Unit, + val onDismiss: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayReissueCardUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayReissueCardUM.kt new file mode 100644 index 0000000000..f08efa4032 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayReissueCardUM.kt @@ -0,0 +1,37 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class TangemPayReissueCardUM( + val feeAmount: String, + val isFeeLoading: Boolean, + val isReissuingInProgress: Boolean, + val error: TangemPayReissueCardError?, + val onConfirmClick: () -> Unit, + val onRetryFee: () -> Unit, + val onAddFundsClick: () -> Unit, + val onDismissRequest: () -> Unit, +) { + companion object { + fun stub( + feeAmount: String = "$4.25", + isFeeLoading: Boolean = false, + error: TangemPayReissueCardError = TangemPayReissueCardError.InitialDataLoading, + isReissuingInProgress: Boolean = false, + ) = TangemPayReissueCardUM( + feeAmount = feeAmount, + isFeeLoading = isFeeLoading, + error = error, + isReissuingInProgress = isReissuingInProgress, + onConfirmClick = {}, + onRetryFee = {}, + onAddFundsClick = {}, + onDismissRequest = {}, + ) + } +} + +internal enum class TangemPayReissueCardError { + InsufficientFunds, InitialDataLoading +} \ No newline at end of file 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 new file mode 100644 index 0000000000..2916fd93ed --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent + +internal class TangemPayCardLimitSetupComponent( + appComponentContext: AppComponentContext, + params: TangemPayDetailsContainerComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayCardLimitSetupModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + BackHandler(onBack = router::pop) + 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 new file mode 100644 index 0000000000..7672c8911c --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -0,0 +1,198 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.format.bigdecimal.anyDecimals +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.findCardWithId +import com.tangem.domain.models.account.requireCardWithId +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.util.Currency +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayCardLimitSetupModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + private val setTangemPayCardLimitUseCase: SetTangemPayCardLimitUseCase, + private val uiMessageSender: UiMessageSender, +) : Model() { + + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayCardLimitSetupUM( + isInitialDataLoading = true, + amountFieldModel = TangemPayCardLimitSetupUM.AmountFieldModel( + value = "", + decimals = 0, + onValueChange = {}, + ), + subtitle = TextReference.EMPTY, + currencyCode = "", + presets = persistentListOf(), + isSubmitButtonEnabled = false, + isSubmitButtonLoading = false, + onSubmitClick = ::onSubmitClick, + onBackClick = router::pop, + ), + ) + + init { + observeCardState() + } + + private fun observeCardState() { + paymentAccountStatusSupplier.invoke(params.userWalletId) + .map { it.value } + .filterIsInstance() + .filter { status -> + status.source == StatusSource.ACTUAL && status.findCardWithId(params.config.cardId) != null + } + .withIndex() + .onEach { (index, status) -> + val card = status.requireCardWithId(params.config.cardId) + + val currentLimit = card.limit?.actualCardLimit + ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } + ?.amount + + val adminLimit = card.limit?.adminCardLimit + ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } + ?.amount + + val currency = getJavaCurrencyByCode(status.currencyCode) + uiState.update { state -> + val amount = if (index == 0) { + currentLimit?.stripTrailingZeros()?.toPlainString().orEmpty() + } else { + state.amountFieldModel.value + } + state.copy( + isInitialDataLoading = false, + amountFieldModel = TangemPayCardLimitSetupUM.AmountFieldModel( + value = amount, + decimals = currency.defaultFractionDigits, + onValueChange = ::onAmountChange, + ), + subtitle = buildSubtitle(adminLimit, currency), + currencyCode = currency.symbol, + presets = buildPresets(currency), + isSubmitButtonEnabled = isValid(amount), + ) + } + } + .launchIn(modelScope) + } + + private fun onAmountChange(newValue: String) { + if (newValue.toBigDecimalOrNull() == null) return + uiState.update { state -> + state.copy( + amountFieldModel = state.amountFieldModel.copy(value = newValue), + isSubmitButtonEnabled = isValid(newValue), + ) + } + } + + private fun onPresetClick(preset: BigDecimal) { + onAmountChange(preset.stripTrailingZeros().toPlainString()) + } + + private fun onSubmitClick() { + val amount = uiState.value.amountFieldModel.value.toBigDecimalOrNull() ?: return + modelScope.launch { + uiState.update { it.copy(isSubmitButtonLoading = true) } + setTangemPayCardLimitUseCase( + cardId = params.config.cardId, + userWalletId = params.userWalletId, + amount = amount, + ).fold( + ifLeft = { + uiState.update { state -> state.copy(isSubmitButtonLoading = false) } + uiMessageSender.send( + DialogMessage( + title = TextReference.Res(R.string.common_something_went_wrong), + message = TextReference.Res(R.string.tangempay_card_limit_setup_error_message), + ), + ) + }, + ifRight = { + uiState.update { state -> state.copy(isSubmitButtonLoading = false) } + router.push(TangemPayCardDetailsInnerRoute.LimitSetupSuccess) + }, + ) + } + } + + private fun isValid(value: String): Boolean { + val amount = value.toBigDecimalOrNull() ?: return false + return amount >= MIN_LIMIT + } + + private fun buildSubtitle(maxLimit: BigDecimal?, currency: Currency): TextReference { + return if (maxLimit == null) { + TextReference.Res( + id = R.string.tangempay_card_limit_setup_amount_subtitle, + formatArgs = WrappedList( + listOf( + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + ), + ), + ) + } else { + TextReference.Res( + id = R.string.tangempay_daily_limit_hint, + formatArgs = WrappedList( + listOf( + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + maxLimit.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + ), + ), + ) + } + } + + private fun buildPresets(currency: Currency) = listOf( + MIN_LIMIT, + BigDecimal("5000"), + BigDecimal("10000"), + BigDecimal("25000"), + ).map { preset -> + val label = preset.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) } + TangemPayCardLimitSetupUM.LimitPresetUM( + label = label, + onClick = { onPresetClick(preset) }, + ) + }.toPersistentList() + + companion object { + private val MIN_LIMIT = BigDecimal.ONE + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreen.kt new file mode 100644 index 0000000000..3ac9114bd2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreen.kt @@ -0,0 +1,182 @@ +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.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.fields.AmountTextField +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.rememberDecimalFormat +import com.tangem.features.tangempay.details.impl.R +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun TangemPayCardLimitSetupScreen(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = stringResourceSafe(R.string.tangempay_card_page_daily_limit_title), + startButton = TopAppBarButtonUM.Close(onCloseClick = state.onBackClick), + ) + }, + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + Content( + state = state, + modifier = Modifier.padding(scaffoldPaddings), + ) + } +} + +@Composable +private fun Content(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .imePadding(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AmountBlock( + modifier = Modifier.padding(horizontal = 16.dp), + state = state, + ) + Spacer(modifier = Modifier.weight(1f)) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResourceSafe(R.string.tangempay_daily_limit_set_button), + enabled = state.isSubmitButtonEnabled, + showProgress = state.isSubmitButtonLoading, + onClick = state.onSubmitClick, + ) + PresetsRow(presets = state.presets) + } +} + +@Composable +private fun AmountBlock(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(TangemTheme.colors.background.action) + .padding(vertical = 48.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResourceSafe(R.string.common_amount), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH12() + if (state.isInitialDataLoading) { + TextShimmer( + style = TangemTheme.typography.head, + text = "$5000", + ) + } 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.colors.text.disabled + } else { + TangemTheme.colors.text.primary1 + }, + ), + textStyle = TangemTheme.typography.head.copy( + textAlign = TextAlign.Center, + ), + isAutoResize = true, + ) + } + Spacer(modifier = Modifier.padding(top = 8.dp)) + Text( + text = state.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun PresetsRow(presets: ImmutableList) { + if (presets.isEmpty()) return + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.button.secondary) + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + 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(16.dp)) + .background(TangemTheme.colors.background.primary) + .clickable(onClick = preset.onClick) + .padding(horizontal = 12.dp, vertical = 4.dp) + .wrapContentHeight(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = preset.label, + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + TangemPayCardLimitSetupScreen( + 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 new file mode 100644 index 0000000000..a7c95a2eb2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute + +internal class TangemPayCardLimitSetupSuccessComponent( + appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Composable + override fun Content(modifier: Modifier) { + BackHandler(onBack = ::backToDetails) + TangemPayCardLimitSetupSuccessScreen( + modifier = modifier, + onDoneClick = ::backToDetails, + ) + } + + private fun backToDetails() { + router.popTo(route = TangemPayCardDetailsInnerRoute.Details) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreen.kt new file mode 100644 index 0000000000..e615ad70f8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreen.kt @@ -0,0 +1,102 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +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.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R + +@Composable +internal fun TangemPayCardLimitSetupSuccessScreen(onDoneClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + startButton = TopAppBarButtonUM.Close(onCloseClick = onDoneClick), + ) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SuccessContent( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + text = stringResourceSafe(R.string.common_done), + onClick = onDoneClick, + ) + } + } +} + +@Composable +private fun SuccessContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_success_blue_76), + tint = Color.Unspecified, + contentDescription = null, + modifier = Modifier.size(76.dp), + ) + SpacerH32() + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_success_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH12() + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_success_description), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TangemThemePreview { + TangemPayCardLimitSetupSuccessScreen(onDoneClick = {}) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupUM.kt new file mode 100644 index 0000000000..e77e83b186 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupUM.kt @@ -0,0 +1,64 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class TangemPayCardLimitSetupUM( + val isInitialDataLoading: Boolean, + val amountFieldModel: AmountFieldModel, + val subtitle: TextReference, + val currencyCode: String, + val presets: ImmutableList, + val isSubmitButtonEnabled: Boolean, + val isSubmitButtonLoading: Boolean, + val onSubmitClick: () -> Unit, + val onBackClick: () -> Unit, +) { + + @Immutable + internal data class AmountFieldModel( + val value: String, + val decimals: Int, + val onValueChange: (String) -> Unit, + ) + + @Immutable + internal data class LimitPresetUM( + val label: String, + val onClick: () -> Unit, + ) + + companion object { + fun stub( + isLoading: Boolean = false, + amountFieldModel: AmountFieldModel = AmountFieldModel( + value = "5000", + decimals = 2, + onValueChange = {}, + ), + subtitle: TextReference = TextReference.Str("Set a limit from $0 to $50,000"), + currencyCode: String = "$", + presets: ImmutableList = persistentListOf( + LimitPresetUM(label = "$0", onClick = {}), + LimitPresetUM(label = "$5,000", onClick = {}), + LimitPresetUM(label = "$10,000", onClick = {}), + LimitPresetUM(label = "$25,000", onClick = {}), + ), + submitButtonEnabled: Boolean = true, + submitButtonLoading: Boolean = false, + ): TangemPayCardLimitSetupUM = TangemPayCardLimitSetupUM( + isInitialDataLoading = isLoading, + amountFieldModel = amountFieldModel, + subtitle = subtitle, + currencyCode = currencyCode, + presets = presets, + isSubmitButtonEnabled = submitButtonEnabled, + isSubmitButtonLoading = submitButtonLoading, + onSubmitClick = {}, + onBackClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt index b1a7a7133a..40b49906f5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt @@ -5,7 +5,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.models.ReceiveAddressModel -import com.tangem.domain.models.ReceiveAddressModel.NameService +import com.tangem.domain.models.ReceiveAddressModel.DisplayType import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -42,7 +42,7 @@ internal class TangemPayAddFundsModel @Inject constructor( depositAddress = params.depositAddress, receiveAddress = listOf( ReceiveAddressModel( - nameService = NameService.Default, + displayType = DisplayType.Default, value = params.depositAddress, ), ), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index fcee864f89..9bcf1fcdd1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.TextReference @@ -13,6 +14,7 @@ import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent @@ -20,6 +22,7 @@ import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransformer import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -42,12 +45,21 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val uiMessageSender: UiMessageSender, private val cardDetailsEventListener: CardDetailsEventListener, private val analytics: AnalyticsEventHandler, + private val router: Router, ) : Model() { private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() private val stateFactory = TangemPayCardDetailsBlockStateFactory( cardNumberEnd = params.params.config.cardNumberEnd, + displayNameState = if (params.isDisplayCardNameEnabled && params.params.config.displayName != null) { + DisplayNameState.Display( + displayName = requireNotNull(params.params.config.displayName).value, + onClick = ::startEditingDisplayName, + ) + } else { + null + }, onReveal = ::revealCardDetails, onCopy = ::copyData, ) @@ -120,6 +132,10 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) } + private fun startEditingDisplayName() { + router.push(TangemPayCardDetailsInnerRoute.EditCardDisplayName) + } + private fun copyData(text: String, type: CardDataType) { val event = when (type) { CardDataType.Number -> TangemPayAnalyticsEvents.CopyCardNumberClicked() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt new file mode 100644 index 0000000000..45a98d4156 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -0,0 +1,319 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.hasCardWithId +import com.tangem.domain.models.account.requireCardWithId +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayTopUpData +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.AddFundsListener +import com.tangem.features.tangempay.components.ReissueCardListener +import com.tangem.features.tangempay.components.TangemPayCardPageComponent +import com.tangem.features.tangempay.components.ViewPinListener +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.* +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute +import com.tangem.features.tangempay.utils.TangemPayMessagesFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class TangemPayCardPageModel @Inject constructor( + paramsContainer: ParamsContainer, + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val analytics: AnalyticsEventHandler, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val uiMessageSender: UiMessageSender, + private val reissueCardRepository: TangemPayReissueCardRepository, +) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { + + private val params: TangemPayCardPageComponent.Params = paramsContainer.require() + + private val addToWalletBannerJobHolder = JobHolder() + private val addFundsJobHolder = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayCardPageUM( + onBackClick = router::pop, + dailyLimitState = TangemPayDailyLimitBlockState.Loading, + settings = persistentListOf(), + ), + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed + init { + fetchAddToWalletBanner() + + paymentAccountStatusSupplier.invoke(params.userWalletId) + .onEach { state -> + val status = state.value + if (status is PaymentAccountStatusValue.Loaded && + status.source == StatusSource.ACTUAL && + status.hasCardWithId(params.config.cardId) + ) { + val card = status.requireCardWithId(params.config.cardId) + val limit = card.limit?.actualCardLimit?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } + val dailyLimitState = if (limit != null) { + TangemPayDailyLimitBlockState.Content( + limit = limit.amount.format { + val symbol = getJavaCurrencyByCode(status.currencyCode).symbol + fiat(status.currencyCode, symbol) + }, + onChangeClick = { router.push(TangemPayCardDetailsInnerRoute.LimitSetup) }, + ) + } else { + TangemPayDailyLimitBlockState.Error + } + uiState.update { it.copy(dailyLimitState = dailyLimitState, settings = buildSettings(card)) } + } else { + uiState.update { it.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error) } + } + } + .launchIn(modelScope) + } + + private fun buildSettings(card: TangemPayCard): ImmutableList { + return persistentListOf( + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_change_pin), + onSettingClick = { onClickChangePIN(card.hasPinCode) }, + testTag = com.tangem.core.ui.test.TangemPayTestTags.CHANGE_PIN_ROW, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + onSettingClick = { onClickFreezeOrUnfreezeCard(card.isFrozen) }, + testTag = com.tangem.core.ui.test.TangemPayTestTags.FREEZE_CARD_ROW, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_reissue_card), + onSettingClick = ::onClickReissueCard, + ), + ) + } + + private fun onClickChangePIN(isPinSet: Boolean) { + if (!isPinSet) { + router.push(TangemPayCardDetailsInnerRoute.ChangePIN) + } else { + bottomSheetNavigation.activate( + TangemPayCardNavigation.ViewPinCode( + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ), + ) + } + } + + private fun onClickFreezeOrUnfreezeCard(isFrozen: Boolean) { + val message = if (isFrozen) { + TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard) + } else { + TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard) + } + uiMessageSender.send(message) + } + + private fun onClickReissueCard() { + analytics.send(TangemPayAnalyticsEvents.ReplaceCardClicked()) + bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard) + } + + override fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) { + bottomSheetNavigation.dismiss() + onReissueOrderStatusReceived(order.orderStatus) + if (order.orderStatus != OrderStatus.CANCELED) { + modelScope.launch { + reissueCardRepository.storeReissueOrderId(params.config.cardId, order.orderId) + } + } else { + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) + } + } + + override fun onDismissReissueCard() { + bottomSheetNavigation.dismiss() + } + + override fun onClickAddFunds() { + bottomSheetNavigation.dismiss() + modelScope.launch { + val balance = cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull() + val depositAddress = balance?.depositAddress + if (balance == null || depositAddress == null) { + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_error))) + return@launch + } + bottomSheetNavigation.activate( + TangemPayCardNavigation.AddFunds( + walletId = params.userWalletId, + fiatBalance = balance.fiatBalance, + cryptoBalance = balance.cryptoBalance, + depositAddress = depositAddress, + chainId = params.config.chainId, + ), + ) + }.saveIn(addFundsJobHolder) + } + + override fun onClickReceive(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + val config = TokenReceiveConfig( + shouldShowWarning = true, + cryptoCurrency = data.currency, + userWalletId = data.walletId, + showMemoDisclaimer = false, + receiveAddress = data.receiveAddress, + ) + bottomSheetNavigation.activate(TangemPayCardNavigation.Receive(config)) + } + + override fun onClickSwap(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + router.push( + AppRoute.Swap( + cryptoCurrency = data.currency, + userWalletId = data.walletId, + currencyPosition = AppRoute.Swap.CurrencyPosition.TO, + screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + tangemPayInput = AppRoute.Swap.TangemPayInput( + cryptoAmount = data.cryptoBalance, + fiatAmount = data.fiatBalance, + depositAddress = data.depositAddress, + isWithdrawal = false, + ), + ), + ) + } + + override fun onDismissAddFunds() { + bottomSheetNavigation.dismiss() + } + + private fun freezeCard() { + modelScope.launch { + cardDetailsRepository.freezeCard( + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ).onLeft { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) + uiMessageSender.send(message) + }.onRight { state -> + val message = if (state == TangemPayCardFrozenState.Frozen) { + SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) + } else { + SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) + } + uiMessageSender.send(message) + } + } + } + + private fun unfreezeCard() { + modelScope.launch { + cardDetailsRepository.unfreezeCard( + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ).onLeft { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) + uiMessageSender.send(message) + }.onRight { state -> + val message = if (state == TangemPayCardFrozenState.Unfrozen) { + SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) + } else { + SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) + } + uiMessageSender.send(message) + } + } + } + + private fun fetchAddToWalletBanner() { + modelScope.launch { + val isDone = cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true + if (!isDone) { + uiState.update { state -> + state.copy( + addToWalletBlockState = AddToWalletBlockState( + onClick = ::onClickAddToWallet, + onClickClose = ::onClickCloseBanner, + ), + ) + } + } + }.saveIn(addToWalletBannerJobHolder) + } + + private fun onClickAddToWallet() { + router.push(TangemPayCardDetailsInnerRoute.AddToWallet) + } + + private fun onClickCloseBanner() { + modelScope.launch { + cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) + uiState.update { it.copy(addToWalletBlockState = null) } + }.saveIn(addToWalletBannerJobHolder) + } + + override fun onClickChangePin() { + bottomSheetNavigation.dismiss() + router.push(TangemPayCardDetailsInnerRoute.ChangePIN) + } + + override fun onDismissViewPin() { + bottomSheetNavigation.dismiss() + } + + private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) { + when (orderStatus) { + OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED -> { + uiState.update { state -> + state.copy( + addToWalletBlockState = null, + isReissueInProgress = true, + ) + } + } + OrderStatus.CANCELED -> Unit + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index de7acccebe..01ff0e469d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -16,12 +16,14 @@ import com.tangem.features.tangempay.components.TangemPayDetailsContainerCompone import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.transformer.update -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import com.tangem.utils.logging.TangemLogger +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -69,7 +71,7 @@ internal class TangemPayChangePinModel @Inject constructor( } SetPinResult.SUCCESS -> { analytics.send(TangemPayAnalyticsEvents.ChangePinSuccessShown()) - router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + router.push(TangemPayCardDetailsInnerRoute.ChangePINSuccess) } SetPinResult.DECRYPTION_ERROR, SetPinResult.UNKNOWN_ERROR, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 1afea757d7..355908fcb0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -15,8 +15,6 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType @@ -30,22 +28,22 @@ import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.tangempay.TangemPayConstants import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent -import com.tangem.features.tangempay.components.ViewPinListener -import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener -import com.tangem.features.tangempay.model.transformers.* -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.model.transformers.DetailBalanceVisibilityTransformer +import com.tangem.features.tangempay.model.transformers.DetailsBalanceTransformer +import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer +import com.tangem.features.tangempay.model.transformers.TangemPayFreezeUnfreezeStateTransformer +import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.utils.TangemPayDetailIntents import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions @@ -81,25 +79,27 @@ internal class TangemPayDetailsModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val expressTransactionsEventListener: ExpressTransactionsEventListener, -) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener, ViewPinListener { +) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() - private val cardFrozenStateConverter = TangemPayCardFrozenStateConverter(onUnfreezeClick = ::onClickUnfreezeCard) private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, onOpenMenu = ::onOpenMenu, intents = this, cardFrozenState = params.config.cardFrozenState, - converter = cardFrozenStateConverter, ) val uiState: StateFlow - field = MutableStateFlow(stateFactory.getInitialState(params.config.isTangemPayDeactivated)) + field = MutableStateFlow( + stateFactory.getInitialState( + isTangemPayDeactivated = params.config.isTangemPayDeactivated, + cardNumberEnd = params.config.cardNumberEnd, + ), + ) private val refreshStateJobHolder = JobHolder() private val fetchBalanceJobHolder = JobHolder() - private val addToWalletBannerJobHolder = JobHolder() private var balance: TangemPayCardBalance? = null @@ -115,7 +115,6 @@ internal class TangemPayDetailsModel @Inject constructor( handleBalanceHiding() fetchBalance() if (!params.config.isTangemPayDeactivated) { - fetchAddToWalletBanner() subscribeToCardFrozenState() } } @@ -135,123 +134,10 @@ internal class TangemPayDetailsModel @Inject constructor( private fun subscribeToCardFrozenState() { cardDetailsRepository .cardFrozenState(params.config.cardId) - .onEach { state -> - uiState.update( - TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = state, - onFreezeClick = ::onClickFreezeCard, - onUnfreezeClick = ::onClickUnfreezeCard, - converter = cardFrozenStateConverter, - ), - ) - } + .onEach { uiState.update(TangemPayFreezeUnfreezeStateTransformer(cardFrozenState = it)) } .launchIn(modelScope) } - override fun onClickPinCode() { - analytics.send(TangemPayAnalyticsEvents.PinCodeClicked()) - if (!params.config.isPinSet) { - router.push(TangemPayDetailsInnerRoute.ChangePIN) - } else { - bottomSheetNavigation.activate( - TangemPayDetailsNavigation.ViewPinCode( - userWalletId = params.userWalletId, - cardId = params.config.cardId, - ), - ) - } - } - - override fun onClickFreezeCard() { - analytics.send(TangemPayAnalyticsEvents.FreezeCardClicked()) - uiMessageSender.send(TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard)) - analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmShown()) - } - - override fun onClickUnfreezeCard() { - analytics.send(TangemPayAnalyticsEvents.UnfreezeCardClicked()) - uiMessageSender.send(TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard)) - analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmShown()) - } - - private fun freezeCard() { - analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmClicked()) - modelScope.launch { - val result = try { - cardDetailsRepository.freezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) - } catch (e: Exception) { - TangemLogger.e("Error", e) - return@launch - } - result - .onLeft { - uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed))) - } - .onRight { state -> - when (state) { - TangemPayCardFrozenState.Frozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)), - ) - uiState.update( - TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = state, - onFreezeClick = ::onClickFreezeCard, - onUnfreezeClick = ::onClickUnfreezeCard, - converter = cardFrozenStateConverter, - ), - ) - } - TangemPayCardFrozenState.Unfrozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)), - ) - } - TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA] - } - } - } - } - - private fun unfreezeCard() { - analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmClicked()) - modelScope.launch { - val result = try { - cardDetailsRepository.unfreezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) - } catch (e: Exception) { - TangemLogger.e("Error", e) - return@launch - } - result - .onLeft { - uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed))) - } - .onRight { state -> - when (state) { - TangemPayCardFrozenState.Unfrozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)), - ) - uiState.update( - TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = state, - onFreezeClick = ::onClickFreezeCard, - onUnfreezeClick = ::onClickUnfreezeCard, - converter = cardFrozenStateConverter, - ), - ) - } - TangemPayCardFrozenState.Frozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)), - ) - } - TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA] - } - } - } - } - override fun onClickAddFunds() { analytics.send(TangemPayAnalyticsEvents.AddFundsClicked()) val currentBalance = balance @@ -313,10 +199,10 @@ internal class TangemPayDetailsModel @Inject constructor( ) { router.push( AppRoute.Swap( - currencyFrom = currency, + cryptoCurrency = currency, userWalletId = params.userWalletId, - isInitialReverseOrder = false, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + currencyPosition = AppRoute.Swap.CurrencyPosition.FROM, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = currentBalance.availableForWithdrawal, fiatAmount = currentBalance.availableForWithdrawal, @@ -351,26 +237,7 @@ internal class TangemPayDetailsModel @Inject constructor( }.launchIn(modelScope) } - private fun fetchAddToWalletBanner() { - modelScope.launch { - val isDone = try { - cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true - } catch (e: Exception) { - TangemLogger.e("Error", e) - return@launch - } - uiState.update( - transformer = DetailsAddToWalletBannerTransformer( - onClickBanner = ::onClickAddToWalletBlock, - onClickCloseBanner = ::onClickCloseAddToWalletBlock, - isDone = isDone, - ), - ) - }.saveIn(addToWalletBannerJobHolder) - } - override fun onContactSupportClicked() { - analytics.send(TangemPayAnalyticsEvents.GoToSupportOnBetaBannerClicked()) analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay)) modelScope.launch { sendFeedbackEmailUseCase.invoke( @@ -393,28 +260,6 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(refreshStateJobHolder) } - private fun onClickAddToWalletBlock() { - analytics.send(TangemPayAnalyticsEvents.AddToWalletClicked()) - router.push(TangemPayDetailsInnerRoute.AddToWallet) - } - - private fun onClickCloseAddToWalletBlock() { - modelScope.launch { - try { - cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) - } catch (e: Exception) { - TangemLogger.e("Error", e) - } - uiState.update( - transformer = DetailsAddToWalletBannerTransformer( - onClickBanner = ::onClickAddToWalletBlock, - onClickCloseBanner = ::onClickCloseAddToWalletBlock, - isDone = true, - ), - ) - }.saveIn(addToWalletBannerJobHolder) - } - private fun onOpenMenu() { analytics.send(TangemPayAnalyticsEvents.CardSettingsClicked()) } @@ -424,10 +269,10 @@ internal class TangemPayDetailsModel @Inject constructor( bottomSheetNavigation.dismiss() router.push( AppRoute.Swap( - currencyFrom = data.currency, + cryptoCurrency = data.currency, userWalletId = data.walletId, - isInitialReverseOrder = true, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + currencyPosition = AppRoute.Swap.CurrencyPosition.TO, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = data.cryptoBalance, fiatAmount = data.fiatBalance, @@ -455,16 +300,6 @@ internal class TangemPayDetailsModel @Inject constructor( bottomSheetNavigation.dismiss() } - override fun onClickChangePin() { - bottomSheetNavigation.dismiss() - analytics.send(TangemPayAnalyticsEvents.ChangePinOnCurrentPinClicked()) - router.push(TangemPayDetailsInnerRoute.ChangePIN) - } - - override fun onDismissViewPin() { - bottomSheetNavigation.dismiss() - } - override fun onTransactionClick(item: TangemPayTxHistoryItem) { val (type, status) = when (item) { is TangemPayTxHistoryItem.Collateral -> "collateral" to "unknown" @@ -486,6 +321,14 @@ internal class TangemPayDetailsModel @Inject constructor( urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } + override fun onCardClick() { + router.push(TangemPayAccountDetailsInnerRoute.CardDetails) + } + + override fun onAddCardClick() { + uiMessageSender.send(message = TangemPayMessagesFactory.createFutureFeature()) + } + private fun showBottomSheetError(type: TangemPayDetailsErrorType) { uiMessageSender.send(message = TangemPayMessagesFactory.createErrorMessage(errorType = type)) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt new file mode 100644 index 0000000000..f0ed2d6e98 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -0,0 +1,96 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayEditDisplayNameModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val uiMessageSender: UiMessageSender, +) : Model() { + + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + + private val originalDisplayName = params.config.displayName?.value.orEmpty() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayEditDisplayNameUM( + editingValue = originalDisplayName, + isLoading = false, + onValueChanged = ::onValueChanged, + onDoneClick = ::onDoneClick, + onDismiss = ::onDismiss, + ), + ) + + private fun onValueChanged(value: String) { + if (value.length <= CardDisplayName.MAX_LENGTH) { + uiState.update { it.copy(editingValue = value) } + } + } + + private fun onDoneClick() { + val currentValue = uiState.value.editingValue + if (currentValue.trim() == originalDisplayName.trim()) { + router.pop() + return + } + CardDisplayName(currentValue) + .onRight { cardDisplayName -> + uiState.update { it.copy(isLoading = true) } + modelScope.launch { + cardDetailsRepository.updateCardDisplayName( + cardId = params.config.cardId, + userWalletId = params.userWalletId, + displayName = cardDisplayName, + ).onRight { + router.pop() + }.onLeft { + uiState.update { state -> state.copy(isLoading = false) } + showError( + titleRes = R.string.tangem_pay_card_details_unable_to_rename_card_title, + messageRes = R.string.tangempay_card_details_unable_to_rename_card_description, + ) + } + } + } + .onLeft { + showError( + titleRes = R.string.tangempay_card_details_rename_card_invalid_title, + messageRes = R.string.tangempay_card_details_rename_card_invalid_description, + ) + } + } + + private fun showError(titleRes: Int, messageRes: Int) { + uiMessageSender.send( + DialogMessage(title = TextReference.Res(titleRes), message = TextReference.Res(messageRes)), + ) + } + + private fun onDismiss() { + router.pop() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt new file mode 100644 index 0000000000..6c454671a1 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt @@ -0,0 +1,119 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.features.tangempay.components.TangemPayReissueCardComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayReissueCardError +import com.tangem.features.tangempay.entity.TangemPayReissueCardUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayReissueCardModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val reissueCardRepository: TangemPayReissueCardRepository, + private val uiMessageSender: UiMessageSender, + private val analytics: AnalyticsEventHandler, +) : Model() { + + private val params = paramsContainer.require() + private val reissueJobHolder = JobHolder() + private val loadDataJobHolder = JobHolder() + + val state: StateFlow + field = MutableStateFlow( + TangemPayReissueCardUM( + feeAmount = "", + isFeeLoading = true, + error = null, + isReissuingInProgress = false, + onConfirmClick = ::onConfirm, + onRetryFee = ::loadData, + onAddFundsClick = { params.listener.onClickAddFunds() }, + onDismissRequest = ::onDismiss, + ), + ) + + init { + analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmationPopupOpened()) + loadData() + } + + fun onDismiss() { + reissueJobHolder.cancel() + params.listener.onDismissReissueCard() + } + + private fun onConfirm() { + analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmed()) + state.update { it.copy(isReissuingInProgress = true) } + modelScope.launch { + reissueCardRepository.reissueCard( + userWalletId = params.userWalletId, + cardId = params.cardId, + ).onLeft { + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) + onDismiss() + }.onRight { order -> + params.listener.onReissueOrderCreate(order) + } + }.saveIn(reissueJobHolder) + } + + private fun loadData() { + state.update { it.copy(isFeeLoading = true, error = null) } + + modelScope.launch { + val (cardBalance, fee) = coroutineScope { + val balanceDeferred = async { cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull() } + val feeDeferred = async { reissueCardRepository.getReissueCardFee(params.userWalletId).getOrNull() } + balanceDeferred.await() to feeDeferred.await() + } + + val error = if (fee == null || cardBalance == null) { + TangemPayReissueCardError.InitialDataLoading + } else if (cardBalance.availableForWithdrawal < fee.amount) { + TangemPayReissueCardError.InsufficientFunds + } else { + null + } + + state.update { state -> + state.copy( + feeAmount = fee?.let { + fee.amount.format { + val symbol = getJavaCurrencyByCode(fee.currencyCode).symbol + fiat(fee.currencyCode, symbol) + } + }.orEmpty(), + isFeeLoading = false, + error = error, + ) + } + }.saveIn(loadDataJobHolder) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt deleted file mode 100644 index 3fe7bddbf8..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.tangem.features.tangempay.entity.AddToWalletBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.utils.transformer.Transformer - -internal class DetailsAddToWalletBannerTransformer( - private val onClickBanner: () -> Unit, - private val onClickCloseBanner: () -> Unit, - private val isDone: Boolean, -) : Transformer { - - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - return prevState.copy( - addToWalletBlockState = if (isDone) { - null - } else { - AddToWalletBlockState(onClick = onClickBanner, onClickClose = onClickCloseBanner) - }, - ) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index d2da85c3d4..af0cec0bd5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -22,19 +22,26 @@ internal class DetailsBalanceTransformer( override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { val balance = when (balance) { is Either.Left -> { - TangemPayDetailsBalanceBlockState.Error(actionButtons = persistentListOf()) + TangemPayDetailsBalanceBlockState.Error( + actionButtons = persistentListOf(), + cardsBlockState = prevState.balanceBlockState.cardsBlockState, + ) } is Either.Right -> { val cryptoCurrency = userWallet?.let { cryptoCurrencyFactory.create(userWallet, balance.value.chainId).getOrNull() } if (cryptoCurrency == null) { - TangemPayDetailsBalanceBlockState.Error(actionButtons = persistentListOf()) + TangemPayDetailsBalanceBlockState.Error( + actionButtons = persistentListOf(), + cardsBlockState = prevState.balanceBlockState.cardsBlockState, + ) } else { TangemPayDetailsBalanceBlockState.Content( isBalanceFlickering = false, fiatBalance = getFiatBalanceText(balance.value), actionButtons = prevState.balanceBlockState.actionButtons, + cardsBlockState = prevState.balanceBlockState.cardsBlockState, ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt deleted file mode 100644 index 1be3c5e765..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.features.tangempay.entity.CardFrozenState -import com.tangem.utils.converter.Converter - -internal class TangemPayCardFrozenStateConverter( - private val onUnfreezeClick: () -> Unit, -) : Converter { - - override fun convert(value: TangemPayCardFrozenState): CardFrozenState { - return when (value) { - TangemPayCardFrozenState.Unfrozen -> CardFrozenState.Unfrozen - TangemPayCardFrozenState.Frozen -> CardFrozenState.Frozen(onUnfreezeClick) - TangemPayCardFrozenState.Pending -> CardFrozenState.Pending - } - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt index 7fb2efcb5e..9ba594e731 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt @@ -1,77 +1,24 @@ package com.tangem.features.tangempay.model.transformers -import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.themedColor -import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItem -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.FreezeCard -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.UnfreezeCard import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList internal class TangemPayFreezeUnfreezeStateTransformer( private val cardFrozenState: TangemPayCardFrozenState, - private val onFreezeClick: () -> Unit, - private val onUnfreezeClick: () -> Unit, - private val converter: TangemPayCardFrozenStateConverter, ) : Transformer { - private val isCardFrozen: Boolean = when (cardFrozenState) { - is TangemPayCardFrozenState.Frozen -> true - is TangemPayCardFrozenState.Unfrozen, is TangemPayCardFrozenState.Pending -> false - } - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val filteredItems = prevState.topBarConfig.items?.filterNot { - it.type == FreezeCard || it.type == UnfreezeCard - } - val dropdownMenuItems = createUpdatedMenuItems(filteredItems?.toPersistentList()) val balanceBlockState = if (prevState.balanceBlockState is TangemPayDetailsBalanceBlockState.Content) { val actionButtons = prevState.balanceBlockState.actionButtons.map { it.copy(isEnabled = cardFrozenState == TangemPayCardFrozenState.Unfrozen) } - prevState.balanceBlockState.copy( - actionButtons = actionButtons.toPersistentList(), - ) + prevState.balanceBlockState.copy(actionButtons = actionButtons.toPersistentList()) } else { prevState.balanceBlockState } - return prevState.copy( - topBarConfig = prevState.topBarConfig.copy(items = dropdownMenuItems), - cardFrozenState = converter.convert(cardFrozenState), - balanceBlockState = balanceBlockState, - ) - } - - private fun createUpdatedMenuItems( - items: ImmutableList?, - ): ImmutableList? { - return items - ?.plus(createMenuItemToAdd(cardFrozenState)) - ?.toPersistentList() - } - - private fun createMenuItemToAdd(cardFrozenState: TangemPayCardFrozenState): TangemPayDetailsTopBarMenuItem { - return TangemPayDetailsTopBarMenuItem( - type = if (isCardFrozen) UnfreezeCard else FreezeCard, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference( - id = if (isCardFrozen) { - R.string.tangempay_card_details_unfreeze_card - } else { - R.string.tangempay_card_details_freeze_card - }, - ), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = if (isCardFrozen) onUnfreezeClick else onFreezeClick, - isEnabled = cardFrozenState != TangemPayCardFrozenState.Pending, - ), - ) + return prevState.copy(balanceBlockState = balanceBlockState) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt new file mode 100644 index 0000000000..6bc9172607 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -0,0 +1,13 @@ +package com.tangem.features.tangempay.navigation + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayAccountDetailsInnerRoute : Route { + @Serializable + data object AccountDetails : TangemPayAccountDetailsInnerRoute() + + @Serializable + data object CardDetails : TangemPayAccountDetailsInnerRoute() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt new file mode 100644 index 0000000000..38d9fb1d83 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.navigation + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayCardDetailsInnerRoute : Route { + + @Serializable + data object Details : TangemPayCardDetailsInnerRoute() + + @Serializable + data object ChangePIN : TangemPayCardDetailsInnerRoute() + + @Serializable + data object ChangePINSuccess : TangemPayCardDetailsInnerRoute() + + @Serializable + data object AddToWallet : TangemPayCardDetailsInnerRoute() + + @Serializable + data object EditCardDisplayName : TangemPayCardDetailsInnerRoute() + + @Serializable + data object LimitSetup : TangemPayCardDetailsInnerRoute() + + @Serializable + data object LimitSetupSuccess : TangemPayCardDetailsInnerRoute() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt deleted file mode 100644 index 7bc24c9979..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.features.tangempay.navigation - -import com.tangem.core.decompose.navigation.Route -import kotlinx.serialization.Serializable - -@Serializable -internal sealed class TangemPayDetailsInnerRoute : Route { - @Serializable - data object Details : TangemPayDetailsInnerRoute() - - @Serializable - data object ChangePIN : TangemPayDetailsInnerRoute() - - @Serializable - data object ChangePINSuccess : TangemPayDetailsInnerRoute() - - @Serializable - data object AddToWallet : TangemPayDetailsInnerRoute() -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt index 7fd6c66c61..6db50884f3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -191,6 +191,7 @@ private fun PreviewTangemPayAddToWalletScreen() { onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 33e91f7745..efe8c8a8dd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -1,27 +1,47 @@ package com.tangem.features.tangempay.ui +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import com.tangem.core.ui.components.SpacerW4 import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition @@ -30,11 +50,15 @@ 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.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.CardDataType +private const val ICON_FADE_DURATION_MS = 300 private val CustomCardBlockColor = Color(0x1F828282) @Suppress("MagicNumber") @@ -75,7 +99,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M shape = RoundedCornerShape(16.dp), ), ) { - if (shouldShowDetails) { + if (shouldShowDetails && state.isActive) { TangemPayCardDetailsShownBlock( cardNumber = state.number, expiry = state.expiry, @@ -88,25 +112,17 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M ) } else { TangemPayCardDetailsHiddenBlock( - cardFrozenState = state.cardFrozenState, - isLoading = state.isLoading, - shortCardNumber = state.numberShort, - onShowDetails = state.onClick, + state = state, ) } } } +@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries") @Composable -private fun TangemPayCardDetailsHiddenBlock( - shortCardNumber: String, - cardFrozenState: TangemPayCardFrozenState, - isLoading: Boolean, - onShowDetails: () -> Unit, - modifier: Modifier = Modifier, -) { +private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize()) { - val imageResId = when (cardFrozenState) { + val imageResId = when (state.cardFrozenState) { is TangemPayCardFrozenState.Frozen -> R.drawable.img_tangem_pay_visa_frozen else -> R.drawable.img_tangem_pay_visa } @@ -115,47 +131,192 @@ private fun TangemPayCardDetailsHiddenBlock( painter = painterResource(id = imageResId), contentDescription = null, ) + Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 16.dp) - .fillMaxWidth(), + modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { + Icon( + painter = painterResource(R.drawable.ic_cloud_fill_16), + tint = TangemTheme.colors.icon.constant, + contentDescription = null, + ) + SpacerW4() Text( - text = shortCardNumber, + text = stringResourceSafe(R.string.tangempay_digital_card), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.constantWhite, ) - when (cardFrozenState) { - is TangemPayCardFrozenState.Frozen -> Icon( - modifier = Modifier - .padding(start = 4.dp) - .size(16.dp), - painter = painterResource(id = R.drawable.ic_snow_24), - contentDescription = null, - tint = TangemTheme.colors.icon.constant, - ) - TangemPayCardFrozenState.Pending -> CircularProgressIndicator( - modifier = Modifier - .padding(start = 4.dp) - .size(16.dp), + } + + if (state.isActive) { + ConstraintLayout( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .fillMaxWidth(), + ) { + val (displayNameRef, cardNumberRef, frozenIconRef, buttonRef) = createRefs() + + if (state.displayNameState != null) { + CardDisplayName( + state = state.displayNameState, + modifier = Modifier.constrainAs(displayNameRef) { + start.linkTo(parent.start) + bottom.linkTo(cardNumberRef.top) + width = Dimension.wrapContent + }, + ) + } + + Text( + text = state.numberShort, + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.constantWhite, - strokeWidth = 1.dp, + modifier = Modifier + .constrainAs(cardNumberRef) { + start.linkTo(parent.start) + bottom.linkTo(parent.bottom) + } + .padding(bottom = 8.dp), + ) + when (state.cardFrozenState) { + is TangemPayCardFrozenState.Frozen -> Icon( + modifier = Modifier + .constrainAs(frozenIconRef) { + start.linkTo(cardNumberRef.end, margin = 4.dp) + top.linkTo(cardNumberRef.top) + bottom.linkTo(cardNumberRef.bottom) + } + .padding(bottom = 8.dp) + .size(16.dp), + painter = painterResource(id = R.drawable.ic_snow_24), + contentDescription = null, + tint = TangemTheme.colors.icon.constant, + ) + TangemPayCardFrozenState.Pending -> CircularProgressIndicator( + modifier = Modifier + .constrainAs(frozenIconRef) { + start.linkTo(cardNumberRef.end, margin = 4.dp) + top.linkTo(cardNumberRef.top) + bottom.linkTo(cardNumberRef.bottom) + } + .padding(bottom = 8.dp) + .size(16.dp), + color = TangemTheme.colors.text.constantWhite, + strokeWidth = 1.dp, + ) + TangemPayCardFrozenState.Unfrozen -> Unit + } + + TangemPayCardDetailsCustomButton( + modifier = Modifier + .constrainAs(buttonRef) { + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + } + .testTag(TangemPayTestTags.CARD_DETAILS_SHOW_BUTTON), + text = stringResourceSafe(id = R.string.tangempay_card_details_show_details), + onClick = state.onClick, + showProgress = state.isLoading, ) - TangemPayCardFrozenState.Unfrozen -> Unit } - SpacerWMax() - TangemPayCardDetailsCustomButton( - modifier = Modifier.padding(bottom = 8.dp), - text = stringResourceSafe(id = R.string.tangempay_card_details_show_details), - onClick = onShowDetails, - showProgress = isLoading, - ) } } } +@Composable +private fun CardDisplayName(state: DisplayNameState, modifier: Modifier = Modifier) { + val isDisplayMode = state is DisplayNameState.Display + + Row( + modifier = modifier.then( + if (state is DisplayNameState.Display) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = state.onClick, + ) + } else { + Modifier + }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + when (state) { + is DisplayNameState.Display -> DisplayOnlyCardDisplayName(state = state) + is DisplayNameState.Editing -> EditingCardDisplayName(state = state) + } + val iconVisibleState = remember { + MutableTransitionState(initialState = !isDisplayMode).apply { + targetState = isDisplayMode + } + } + AnimatedVisibility( + visibleState = iconVisibleState, + enter = fadeIn(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)), + exit = fadeOut(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_edit_new_12), + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = TangemTheme.colors.text.constantWhite, + ) + } + } + } +} + +@Composable +private fun DisplayOnlyCardDisplayName(state: DisplayNameState.Display, modifier: Modifier = Modifier) { + Text( + text = state.displayName, + style = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite), + maxLines = 1, + modifier = modifier, + ) +} + +@Composable +private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Modifier = Modifier) { + val focusRequester = remember { FocusRequester() } + var textFieldValue by remember(state.editingValue) { + mutableStateOf( + TextFieldValue(text = state.editingValue, selection = TextRange(state.editingValue.length)), + ) + } + + val textStyle = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite) + val textMeasurer = rememberTextMeasurer() + val textWidthDp = with(LocalDensity.current) { + textMeasurer.measure(textFieldValue.text, textStyle).size.width.toDp() + 2.dp + } + + BasicTextField( + value = textFieldValue, + onValueChange = { newValue -> + if (newValue.text.length in 0..CardDisplayName.MAX_LENGTH) { + textFieldValue = newValue + state.onValueChanged(newValue.text) + } + }, + modifier = modifier + .width(textWidthDp.coerceAtLeast(1.dp)) + .focusRequester(focusRequester), + textStyle = textStyle, + singleLine = true, + cursorBrush = SolidColor(TangemTheme.colors.text.constantWhite), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { state.onSubmit() }), + ) + + LaunchedEffect(Unit) { focusRequester.requestFocus() } +} + @Suppress("MagicNumber", "LongParameterList") @Composable private fun TangemPayCardDetailsShownBlock( @@ -179,6 +340,8 @@ private fun TangemPayCardDetailsShownBlock( title = stringResourceSafe(R.string.tangempay_card_details_card_number), text = cardNumber, onCopy = onCopyCardNumber, + valueTestTag = TangemPayTestTags.CARD_DETAILS_NUMBER_VALUE, + copyTestTag = TangemPayTestTags.CARD_DETAILS_COPY_NUMBER, ) Row( modifier = Modifier @@ -193,6 +356,8 @@ private fun TangemPayCardDetailsShownBlock( title = stringResourceSafe(R.string.tangempay_card_details_expiry), text = expiry, onCopy = onCopyExpiry, + valueTestTag = TangemPayTestTags.CARD_DETAILS_EXPIRATION_VALUE, + copyTestTag = TangemPayTestTags.CARD_DETAILS_COPY_EXPIRATION, ) CardDetailsTextContainer( modifier = Modifier @@ -201,13 +366,17 @@ private fun TangemPayCardDetailsShownBlock( title = stringResourceSafe(R.string.tangempay_card_details_cvc), text = cvv, onCopy = onCopyCvv, + valueTestTag = TangemPayTestTags.CARD_DETAILS_CVC_VALUE, + copyTestTag = TangemPayTestTags.CARD_DETAILS_COPY_CVC, ) } Spacer(modifier = Modifier.weight(1f)) Row { SpacerWMax() TangemPayCardDetailsCustomButton( - modifier = Modifier.padding(end = 16.dp, bottom = 8.dp), + modifier = Modifier + .padding(end = 16.dp, bottom = 8.dp) + .testTag(TangemPayTestTags.CARD_DETAILS_HIDE_BUTTON), text = stringResourceSafe(id = R.string.tangempay_card_details_hide_details), onClick = onHideDetails, showProgress = false, @@ -217,7 +386,14 @@ private fun TangemPayCardDetailsShownBlock( } @Composable -private fun CardDetailsTextContainer(title: String, text: String, onCopy: () -> Unit, modifier: Modifier = Modifier) { +private fun CardDetailsTextContainer( + title: String, + text: String, + onCopy: () -> Unit, + modifier: Modifier = Modifier, + valueTestTag: String? = null, + copyTestTag: String? = null, +) { Row( modifier = modifier .background( @@ -238,10 +414,13 @@ private fun CardDetailsTextContainer(title: String, text: String, onCopy: () -> text = text, style = TangemTheme.typography.body2, color = TangemTheme.colors.text.constantWhite, + modifier = if (valueTestTag != null) Modifier.testTag(valueTestTag) else Modifier, ) } IconButton( - modifier = Modifier.size(TangemTheme.dimens.size32), + modifier = Modifier + .size(TangemTheme.dimens.size32) + .then(if (copyTestTag != null) Modifier.testTag(copyTestTag) else Modifier), onClick = onCopy, ) { Icon( @@ -313,6 +492,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Frozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -325,6 +505,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -337,6 +518,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Pending, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -349,6 +531,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "12/34", cvv = "123", cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), ), ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt new file mode 100644 index 0000000000..4b8dbc7446 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -0,0 +1,234 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resolveReference +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.TangemThemePreview +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.DisplayNameState +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayCardPageSetting +import com.tangem.features.tangempay.entity.TangemPayCardPageUM +import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState +import kotlinx.collections.immutable.ImmutableList + +private const val CONTENT_FADE_DURATION_MS = 300 + +@Composable +internal fun TangemPayCardPageScreen( + state: TangemPayCardPageUM, + cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + cardDetailsState: TangemPayCardDetailsUM, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + AppBarWithBackButton( + modifier = Modifier.statusBarsPadding(), + onBackClick = state.onBackClick, + ) + }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(scaffoldPaddings), + contentPadding = PaddingValues( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + item(key = "Card") { + cardDetailsBlockComponent.CardDetailsBlockContent( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + state = cardDetailsState, + ) + } + if (state.addToWalletBlockState != null) { + cardPageItem(key = "GooglePay") { + TangemPayAddToWalletBlock(state = state.addToWalletBlockState) + } + } + cardPageItem(key = "Limit") { + TangemPayDailyLimitBlock(state = state.dailyLimitState) + } + if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) { + cardPageItem(key = "LimitError") { + TangemPayDailyLimitErrorBlock() + } + } + cardPageItem(key = "Settings") { + if (state.isReissueInProgress) { + TangemPayReplacingCardBlock() + } else { + TangemPayCardPageSettingsBlock(settings = state.settings) + } + } + } + } +} + +@Composable +private fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) { + Notification( + modifier = modifier, + config = NotificationConfig( + iconResId = com.tangem.core.ui.R.drawable.ic_update_32, + iconTint = NotificationConfig.IconTint.Accent, + title = resourceReference(R.string.tangempay_reissue_card_in_progress), + subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), + ), + ) +} + +@Composable +private fun TangemPayCardPageSettingsBlock( + settings: ImmutableList, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ), + ) { + Text( + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing4, + ), + text = stringResourceSafe(R.string.tangempay_card_page_settings_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + settings.fastForEach { item -> + TangemPayCardPageSettingRow( + item = item, + onClick = item.onSettingClick, + ) + } + } +} + +@Composable +private fun TangemPayCardPageSettingRow( + item: TangemPayCardPageSetting, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(TangemTheme.dimens.spacing12) + .then(if (item.testTag != null) Modifier.testTag(item.testTag) else Modifier), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = item.title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +private fun LazyListScope.cardPageItem( + key: Any? = null, + contentType: Any? = null, + content: @Composable LazyItemScope.() -> Unit, +) { + item( + key = key, + contentType = contentType, + ) { + val visibleState = remember { MutableTransitionState(false).apply { targetState = true } } + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + ) { + content() + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + TangemPayCardPageScreen( + state = TangemPayCardPageUM.stub(), + cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( + TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + numberShort = "··1245", + expiry = "••/••", + cvv = "•••", + onCopy = { _, _ -> }, + onClick = {}, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), + ), + ), + cardDetailsState = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + numberShort = "··1245", + expiry = "••/••", + cvv = "•••", + onCopy = { _, _ -> }, + onClick = {}, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), + ), + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt index d1c902a313..eda90333b9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -23,6 +24,7 @@ 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.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.features.tangempay.details.impl.R @Composable @@ -43,7 +45,7 @@ internal fun TangemPayChangePinCodeSuccessScreen(onClick: () -> Unit, modifier: titleAlignment = Alignment.CenterHorizontally, ) Column( - modifier + modifier = Modifier .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -58,7 +60,8 @@ internal fun TangemPayChangePinCodeSuccessScreen(onClick: () -> Unit, modifier: .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 16.dp) - .navigationBarsPadding(), + .navigationBarsPadding() + .testTag(TangemPayTestTags.PIN_DONE_BUTTON), text = stringResourceSafe(R.string.common_done), onClick = onClick, ) @@ -101,14 +104,18 @@ private fun SuccessContent(modifier: Modifier = Modifier) { ) SpacerH32() Text( - modifier = Modifier.padding(horizontal = 32.dp), + modifier = Modifier + .padding(horizontal = 32.dp) + .testTag(TangemPayTestTags.PIN_SUCCESS_TITLE), text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, ) SpacerH12() Text( - modifier = Modifier.padding(horizontal = 32.dp), + modifier = Modifier + .padding(horizontal = 32.dp) + .testTag(TangemPayTestTags.PIN_SUCCESS_DESCRIPTION), text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_description), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt index c1eaca6abc..88a3a9ca2e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color.Companion.Transparent import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -31,6 +32,7 @@ import com.tangem.core.ui.extensions.resolveReference 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.test.TangemPayTestTags import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM import kotlinx.coroutines.delay @@ -52,7 +54,7 @@ internal fun TangemPayChangePinScreen( ) Column( - modifier = modifier + modifier = Modifier .fillMaxWidth() .padding(top = 48.dp) .padding(horizontal = 36.dp) @@ -64,6 +66,7 @@ internal fun TangemPayChangePinScreen( style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, + modifier = Modifier.testTag(TangemPayTestTags.PIN_SCREEN_TITLE), ) SpacerH16() @@ -73,6 +76,7 @@ internal fun TangemPayChangePinScreen( style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, + modifier = Modifier.testTag(TangemPayTestTags.PIN_SCREEN_DESCRIPTION), ) SpacerH(26.dp) @@ -84,7 +88,8 @@ internal fun TangemPayChangePinScreen( modifier = Modifier .imePadding() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(TangemPayTestTags.PIN_SUBMIT_BUTTON), primaryButton = NavigationButton( textReference = resourceReference(R.string.common_submit), onClick = state.onSubmitClick, @@ -117,6 +122,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.warning, textAlign = TextAlign.Center, + modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE), ) } } @@ -150,7 +156,8 @@ private fun PinCode( .clickable { focusRequester.requestFocus() keyboardController?.show() - }, + } + .testTag(TangemPayTestTags.PIN_INPUT_FIELD), textStyle = TangemTheme.typography.h1.copy(color = Transparent), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt new file mode 100644 index 0000000000..c79c7aa582 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt @@ -0,0 +1,140 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +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.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState + +@Composable +internal fun TangemPayDailyLimitBlock(state: TangemPayDailyLimitBlockState, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(TangemTheme.dimens.spacing12), + ) { + Text( + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH12() + CurrentLimitBlock(state) + } +} + +@Composable +private fun CurrentLimitBlock(state: TangemPayDailyLimitBlockState) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_limit_20), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + SpacerW12() + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_current_limit), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + when (state) { + TangemPayDailyLimitBlockState.Error, + is TangemPayDailyLimitBlockState.Content, + -> Text( + text = if (state is TangemPayDailyLimitBlockState.Content) state.limit else "—", + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TangemPayDailyLimitBlockState.Loading -> TextShimmer( + style = TangemTheme.typography.subtitle1, + text = "$50,000", + ) + } + } + SpacerW8() + if (state is TangemPayDailyLimitBlockState.Content) { + SecondaryButton( + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_change), + onClick = state.onChangeClick, + size = TangemButtonSize.Small, + ) + } + } +} + +@Composable +internal fun TangemPayDailyLimitErrorBlock(modifier: Modifier = Modifier) { + Notification( + config = NotificationConfig( + title = resourceReference(R.string.tangempay_card_page_daily_limit_error_title), + subtitle = resourceReference(R.string.tangempay_card_page_daily_limit_error_description), + iconResId = R.drawable.img_attention_20, + ), + containerColor = TangemTheme.colors.background.action, + modifier = modifier.fillMaxWidth(), + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Content.stub()) + TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Error) + TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Loading) + TangemPayDailyLimitErrorBlock() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 2ecb3a0fc6..2349905b14 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -2,17 +2,26 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -21,35 +30,36 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEach import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.buttons.HorizontalActionChips import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.buttons.small.TangemIconButton import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.orMaskWithStars 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.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.core.ui.test.TokenDetailsTopBarTestTags -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.* +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @@ -59,7 +69,6 @@ import kotlinx.collections.immutable.persistentListOf internal fun TangemPayDetailsScreen( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, expressTransactionsComponent: ExpressTransactionsComponent, modifier: Modifier = Modifier, ) { @@ -72,7 +81,6 @@ internal fun TangemPayDetailsScreen( val listState = rememberLazyListState() val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() - val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() val expressTransactionsBottomSheetState = expressState.bottomSheetSlot @@ -87,42 +95,12 @@ internal fun TangemPayDetailsScreen( bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, ), ) { - if (state.accountDeactivatedNotificationConfig == null) { - item(TangemPayCardDetailsUM::class.java) { - cardDetailsBlockComponent.CardDetailsBlockContent( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - state = cardDetailsState, - ) - SpacerH12() - } - } - when (state.cardFrozenState) { - is CardFrozenState.Frozen -> item(CardFrozenState.Frozen::class.java) { - PrimaryButton( - modifier = Modifier - .animateItem() - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = stringResourceSafe(R.string.tangempay_card_details_unfreeze_card), - onClick = state.cardFrozenState.onUnfreeze, - ) - SpacerH12() - } - else -> Unit - } - if (state.addToWalletBlockState != null) { - item( - key = AddToWalletBlockState::class.java, - content = { - TangemPayAddToWalletBlock( - state = state.addToWalletBlockState, - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16), - ) - SpacerH12() - }, + item(key = "title") { + TangemPayTitle( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(top = 4.dp) + .fillMaxWidth(), ) } item( @@ -152,20 +130,6 @@ internal fun TangemPayDetailsScreen( }, ) } - if (state.betaNotificationConfig != null) { - item( - key = "TANGEM_PAY_IS_IN_BETA", - content = { - TangemPayBetaBlock( - modifier = modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - config = state.betaNotificationConfig, - ) - SpacerH12() - }, - ) - } if (state.accountDeactivatedNotificationConfig == null) { with(expressTransactionsComponent) { expressTransactionsContent( @@ -184,8 +148,59 @@ internal fun TangemPayDetailsScreen( } @Composable -private fun TangemPayBetaBlock(config: NotificationConfig, modifier: Modifier = Modifier) { - Notification(modifier = modifier, config = config) +private fun TangemPayTitle(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = stringResourceSafe(R.string.tangempay_payment_account), + style = TangemTheme.typography.head, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + ) + TangemPaySubtitle() + } +} + +@Suppress("MagicNumber") +@Composable +private fun TangemPaySubtitle(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(modifier = Modifier.wrapContentWidth()) { + Icon( + painter = painterResource(id = R.drawable.ic_polygon_22), + contentDescription = null, + tint = TangemTheme.colors.text.constantWhite, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .border(width = 2.dp, color = TangemTheme.colors.background.secondary, shape = CircleShape) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF8247E5)) + .size(16.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS), + ) + Image( + painter = painterResource(id = R.drawable.img_usdc_16), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .border(width = 2.dp, color = TangemTheme.colors.background.secondary, shape = CircleShape) + .padding(2.dp) + .clip(CircleShape) + .size(16.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS), + ) + } + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResourceSafe(R.string.tangempay_usdc_on_polygon_network), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + ) + } } // region Balance block @@ -205,7 +220,7 @@ private fun TangemPayDetailsBalanceBlock( ) { Text( modifier = Modifier.padding(start = 12.dp), - text = stringResourceSafe(R.string.tangempay_title), + text = stringResourceSafe(R.string.common_balance_title), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, ) @@ -214,6 +229,12 @@ private fun TangemPayDetailsBalanceBlock( state = state, isBalanceHidden = isBalanceHidden, ) + CardsBlockRow( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + cardsBlockState = state.cardsBlockState, + ) if (state.actionButtons.isNotEmpty()) { HorizontalActionChips( modifier = Modifier.padding(top = 12.dp), @@ -225,6 +246,51 @@ private fun TangemPayDetailsBalanceBlock( } } +@Composable +private fun CardsBlockRow( + cardsBlockState: TangemPayDetailsBalanceBlockState.CardsBlockState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val itemsModifier = Modifier.size(width = 48.dp, height = 32.dp) + cardsBlockState.cards.fastForEach { card -> + TangemPayCardItem(modifier = itemsModifier, card = card) + } + TangemIconButton( + modifier = itemsModifier, + onClick = cardsBlockState.onAddCardClick, + iconRes = R.drawable.ic_plus_24, + shape = RoundedCornerShape(4.dp), + ) + } +} + +@Composable +private fun TangemPayCardItem(card: TangemPayDetailsBalanceBlockState.Card, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = card.onClick), + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(R.drawable.img_visa_card_48x32), + contentDescription = null, + ) + Text( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp, bottom = 2.dp), + text = card.lastDigits, + style = TangemTheme.typography.overline.copy(letterSpacing = 0.sp), + color = TangemTheme.colors.text.constantWhite, + ) + } +} + @Composable private fun FiatBalance( state: TangemPayDetailsBalanceBlockState, @@ -239,7 +305,7 @@ private fun FiatBalance( ), ) is TangemPayDetailsBalanceBlockState.Content -> Text( - modifier = modifier, + modifier = modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), text = state.fiatBalance.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.h2.applyBladeBrush( isEnabled = state.isBalanceFlickering, @@ -247,7 +313,7 @@ private fun FiatBalance( ), ) is TangemPayDetailsBalanceBlockState.Error -> Text( - modifier = modifier, + modifier = modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), text = DASH_SIGN.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, @@ -273,7 +339,7 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi }, title = {}, actions = { - AnimatedVisibility(visible = config.items != null && config.items.isNotEmpty()) { + AnimatedVisibility(visible = config.items.isNotEmpty()) { IconButton( onClick = { config.onOpenMenu() @@ -294,9 +360,9 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi modifier = Modifier.background(TangemTheme.colors.background.primary), onDismissRequest = { showDropdownMenu = false }, content = { - config.items?.fastForEach { + config.items.fastForEach { item -> TangemDropdownItem( - item = it.dropdownItem, + item = item, dismissParent = { showDropdownMenu = false }, ) } @@ -324,18 +390,6 @@ private fun TangemPayDetailsScreenPreview( txHistoryComponent = PreviewTangemPayTxHistoryComponent( txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, ), - cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( - TangemPayCardDetailsUM( - number = "•••• •••• •••• 1245", - numberShort = "*1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - ), - ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } @@ -344,7 +398,7 @@ private fun TangemPayDetailsScreenPreview( private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( collection = listOf( TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = null), + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = persistentListOf()), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), balanceBlockState = TangemPayDetailsBalanceBlockState.Content( actionButtons = persistentListOf( @@ -356,41 +410,30 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider }, - onClick = {}, - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - ), - ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt new file mode 100644 index 0000000000..ffe3a645b9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt @@ -0,0 +1,82 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM + +@Composable +internal fun TangemPayEditDisplayNameScreen( + state: TangemPayEditDisplayNameUM, + cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + cardDetailsState: TangemPayCardDetailsUM, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary) + .navigationBarsPadding(), + ) { + Box( + modifier = Modifier + .statusBarsPadding() + .height(56.dp) + .fillMaxWidth(), + ) { + IconButton( + modifier = Modifier.padding(start = 4.dp, top = 4.dp), + onClick = state.onDismiss, + ) { + Icon( + painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + } + } + + cardDetailsBlockComponent.CardDetailsBlockContent( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp), + state = cardDetailsState, + ) + + Spacer(modifier = Modifier.weight(1f)) + + NavigationPrimaryButton( + modifier = Modifier + .imePadding() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + primaryButton = NavigationButton( + textReference = resourceReference(R.string.common_done), + onClick = state.onDoneClick, + shouldShowProgress = state.isLoading, + isEnabled = !state.isLoading && state.editingValue.isNotBlank(), + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContent.kt new file mode 100644 index 0000000000..9048249e1d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContent.kt @@ -0,0 +1,218 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +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.res.painterResource +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.PrimaryButton +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +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.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayReissueCardError +import com.tangem.features.tangempay.entity.TangemPayReissueCardUM + +@Composable +internal fun TangemPayReissueCardContent(state: TangemPayReissueCardUM) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.onDismissRequest, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + onBack = state.onDismissRequest, + title = { + TangemModalBottomSheetTitle( + title = TextReference.EMPTY, + endIconRes = R.drawable.ic_close_24, + onEndClick = state.onDismissRequest, + ) + }, + ) { + Content(state) + } +} + +@Composable +private fun Content(state: TangemPayReissueCardUM) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(TangemTheme.dimens.spacing56) + .clip(CircleShape) + .background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_update_32), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier.size(32.dp), + ) + } + + SpacerH24() + + Text( + text = stringResourceSafe(R.string.tangempay_reissue_card_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + SpacerH8() + + Text( + text = stringResourceSafe(R.string.tangempay_reissue_card_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + SpacerH24() + + FeeBlock(state) + + if (state.error != null) { + SpacerH16() + ErrorBlock( + error = state.error, + onRetryFee = state.onRetryFee, + onAddFundsClick = state.onAddFundsClick, + ) + } + + SpacerH24() + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.tangempay_reissue_card_confirm), + enabled = state.error == null && !state.isFeeLoading, + showProgress = state.isReissuingInProgress, + onClick = state.onConfirmClick, + ) + + SpacerH16() + } +} + +@Composable +private fun FeeBlock(state: TangemPayReissueCardUM) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResourceSafe(R.string.tangempay_reissue_card_fee_label), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + AnimatedContent( + targetState = when { + state.isFeeLoading -> null + state.error == TangemPayReissueCardError.InitialDataLoading -> "—" + else -> state.feeAmount + }, + ) { fee -> + if (fee != null) { + Text( + text = fee, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + } else { + TextShimmer(style = TangemTheme.typography.body1, text = "$0.00") + } + } + } +} + +@Composable +private fun ErrorBlock(error: TangemPayReissueCardError, onAddFundsClick: () -> Unit, onRetryFee: () -> Unit) { + when (error) { + TangemPayReissueCardError.InsufficientFunds -> Notification( + config = NotificationConfig( + title = resourceReference(R.string.tangempay_reissue_card_insufficient_funds_title), + subtitle = resourceReference(R.string.tangempay_reissue_card_insufficient_funds_subtitle), + iconResId = R.drawable.img_usdc_16, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.tangempay_card_details_add_funds), + iconResId = R.drawable.ic_plus_24, + onClick = onAddFundsClick, + ), + ), + containerColor = TangemTheme.colors.background.action, + modifier = Modifier.fillMaxWidth(), + ) + TangemPayReissueCardError.InitialDataLoading -> Notification( + config = NotificationConfig( + title = resourceReference(R.string.tangempay_reissue_card_fee_unreachable_error_title), + subtitle = resourceReference(R.string.send_fee_unreachable_error_text), + iconResId = R.drawable.img_attention_20, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_button_refresh), + onClick = onRetryFee, + ), + ), + containerColor = TangemTheme.colors.background.action, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + TangemPayReissueCardContent( + state = TangemPayReissueCardUM.stub(), + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt index 9e96a75104..731154d2a1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt @@ -7,8 +7,7 @@ internal interface TangemPayDetailIntents { fun onRefreshSwipe(refreshState: ShowRefreshState) fun onClickAddFunds() fun onClickWithdraw() - fun onClickPinCode() - fun onClickFreezeCard() - fun onClickUnfreezeCard() fun onClickTermsAndLimits() + fun onCardClick() + fun onAddCardClick() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index 5f8a35fffc..e10ee99a4e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -113,4 +113,20 @@ internal object TangemPayMessagesFactory { } } } + + fun createFutureFeature(): BottomSheetMessage { + return bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_credit_card_add_24) { + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Accent + } + title = resourceReference(R.string.tangempay_feature_will_be_available_soon) + body = resourceReference(R.string.tangempay_feature_will_be_available_soon_description) + } + primaryButton { + text = resourceReference(R.string.common_got_it) + onClick { closeBs() } + } + } + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt index fa798e16bc..608f354d94 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt @@ -60,9 +60,7 @@ internal class TangemPayTxHistoryListManager( } suspend fun loadMore() { - actionsFlow.emit( - BatchAction.LoadMore(requestParams = TangemPayTxHistoryListConfig(shouldRefresh = false)), - ) + actionsFlow.emit(BatchAction.LoadMore(requestParams = null)) } private fun updateState(batchListState: BatchListState>) { 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 new file mode 100644 index 0000000000..3213e42482 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -0,0 +1,132 @@ +package com.tangem.features.tangempay.limit.setup + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.models.StatusSource +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.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayCardLimitSetupModelTest { + + private val cardId = "test_card_id" + private val userWalletId = UserWalletId("123") + + private val router: Router = mockk(relaxed = true) + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val setLimitUseCase: SetTangemPayCardLimitUseCase = mockk(relaxed = true) + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + + private val params = TangemPayDetailsContainerComponent.Params( + userWalletId = userWalletId, + config = TangemPayDetailsConfig( + customerId = "customer1", + cardId = cardId, + isPinSet = false, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + cardNumberEnd = "1234", + chainId = 1, + isTangemPayDeactivated = false, + displayName = null, + ), + ) + + private val testCard = TangemPayCard( + id = cardId, + hasPinCode = false, + displayName = null, + limit = null, + isFrozen = false, + lastDigits = "1234", + ) + + private val loadedStatus: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { + every { source } returns StatusSource.ACTUAL + every { cards } returns listOf(testCard) + every { currencyCode } returns "USD" + } + + private val paymentStatus: AccountStatus.Payment = mockk(relaxed = true) { + every { value } returns loadedStatus + } + + private fun createModel(): TangemPayCardLimitSetupModel { + every { paymentAccountStatusSupplier.invoke(userWalletId) } returns flowOf(paymentStatus) + return TangemPayCardLimitSetupModel( + paramsContainer = MutableParamsContainer(params), + dispatchers = TestingCoroutineDispatcherProvider(), + router = router, + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + setTangemPayCardLimitUseCase = setLimitUseCase, + uiMessageSender = uiMessageSender, + ) + } + + @ParameterizedTest + @MethodSource("provideTestCases") + fun `GIVEN amount WHEN changed THEN submit button reflects validity`( + amount: String, + expectedEnabled: Boolean, + ) { + val model = createModel() + + model.uiState.value.amountFieldModel.onValueChange(amount) + + assertThat(model.uiState.value.isSubmitButtonEnabled).isEqualTo(expectedEnabled) + model.onDestroy() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Presets { + + @Test + fun `WHEN first preset clicked THEN amount set to MIN_LIMIT`() { + val model = createModel() + + model.uiState.value.presets.first().onClick() + + assertThat(model.uiState.value.amountFieldModel.value).isEqualTo("1") + model.onDestroy() + } + + @Test + fun `WHEN last preset clicked THEN amount set to 5000`() { + val model = createModel() + + model.uiState.value.presets.last().onClick() + + assertThat(model.uiState.value.amountFieldModel.value).isEqualTo("25000") + model.onDestroy() + } + } + + private fun provideTestCases() = listOf( + Arguments.of("0", false), + Arguments.of("0.99", false), + Arguments.of("1", true), + Arguments.of("100", true), + Arguments.of("-1", false), + Arguments.of("", false), + Arguments.of("abc", false), + ) +} \ No newline at end of file diff --git a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt index 54da2fa3ec..deecec789f 100644 --- a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt +++ b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign @@ -31,6 +32,7 @@ import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.features.tangempay.entity.TangemPayMainUM import com.tangem.features.tangempay.main.impl.R import com.tangem.utils.StringsSigns.DASH_SIGN @@ -90,7 +92,8 @@ private fun TangemPayMainContent( modifier = modifier .clip(RoundedCornerShape(size = 18.dp)) .background(TangemTheme.colors2.surface.level3) - .clickableSingle(onClick = payMainUM.onClick), + .clickableSingle(onClick = payMainUM.onClick) + .testTag(TangemPayTestTags.MAIN_SCREEN_TILE), ) { Image( painter = painterResource(R.drawable.img_visa_36), diff --git a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt index e95dcc262b..f72aecb6f4 100644 --- a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt +++ b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -29,6 +30,7 @@ import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.features.tangempay.entity.TangemPayMainUM import com.tangem.features.tangempay.main.impl.R import com.tangem.utils.StringsSigns.DASH_SIGN @@ -57,7 +59,7 @@ private fun TangemPayMainBlockContent( modifier: Modifier = Modifier, ) { Surface( - modifier = modifier, + modifier = modifier.testTag(TangemPayTestTags.MAIN_SCREEN_TILE), shape = TangemTheme.shapes.roundedCornersXMedium, color = TangemTheme.colors.background.primary, onClick = state.onClick, diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 3c61c2ed70..f96c63f4b4 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -31,8 +31,6 @@ dependencies { /** Domain modules */ implementation(projects.domain.account) - implementation(projects.domain.appTheme) - implementation(projects.domain.appTheme.models) implementation(projects.domain.card) implementation(projects.domain.feedback) implementation(projects.domain.markets.models) @@ -42,6 +40,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.wallets) implementation(projects.domain.feedback.models) + implementation(projects.domain.settings) implementation(projects.data.common) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 76d4e77f3a..7ed452749d 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -80,7 +80,11 @@ internal class TesterActivity : ComposeActivity() { private fun TesterNavHost() { val navController = rememberNavController().also { innerTesterRouter.setNavController(it) } - NavHost(navController = navController, startDestination = TesterScreen.MENU.name) { + NavHost( + modifier = Modifier.systemBarsPadding(), + navController = navController, + startDestination = TesterScreen.MENU.name, + ) { composable(route = TesterScreen.MENU.name) { TesterMenuScreen( state = TesterMenuUM( @@ -114,7 +118,6 @@ internal class TesterActivity : ComposeActivity() { innerTesterRouter.open(route) }, ), - modifier = Modifier.systemBarsPadding(), ) } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt index 9307de43a7..fe09060e52 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt @@ -1,23 +1,23 @@ package com.tangem.feature.tester.presentation.actions import androidx.compose.runtime.Immutable -import com.tangem.domain.apptheme.model.AppThemeMode import java.io.File internal data class TesterActionsContentState( val hideAllCurrenciesUM: HideAllCurrenciesUM, - val toggleAppThemeUM: ToggleAppThemeUM, + val toggleHotWalletRestrictionUM: ToggleHotWalletRestrictionUM, val shareLogsUM: ShareLogsUM, val onBackClick: () -> Unit, ) { + @Immutable sealed class HideAllCurrenciesUM { data class Clickable(val onClick: () -> Unit) : HideAllCurrenciesUM() data object Progress : HideAllCurrenciesUM() } - data class ToggleAppThemeUM( - val currentAppTheme: AppThemeMode, + data class ToggleHotWalletRestrictionUM( + val isEnabled: Boolean, val onClick: () -> Unit, ) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt index bad48fc458..a3e947b392 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt @@ -22,10 +22,9 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.findActivity -import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.feature.tester.impl.R import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.HideAllCurrenciesUM -import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleAppThemeUM +import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleHotWalletRestrictionUM import com.tangem.utils.logging.TangemLogger import java.io.File @@ -57,10 +56,11 @@ internal fun TesterActionsScreen(state: TesterActionsContentState, modifier: Mod } item { - val config = state.toggleAppThemeUM + val config = state.toggleHotWalletRestrictionUM + val statusText = if (config.isEnabled) "ON" else "OFF" TesterActionItem( - name = stringResourceSafe(id = R.string.toggle_app_theme, config.currentAppTheme.name), + name = stringResourceSafe(id = R.string.toggle_hot_wallet_restriction, statusText), onClick = config.onClick, ) } @@ -125,7 +125,7 @@ private fun TesterActionsScreenSample(modifier: Modifier = Modifier) { TesterActionsScreen( state = TesterActionsContentState( hideAllCurrenciesUM = HideAllCurrenciesUM.Clickable {}, - toggleAppThemeUM = ToggleAppThemeUM(AppThemeMode.DEFAULT) {}, + toggleHotWalletRestrictionUM = ToggleHotWalletRestrictionUM(isEnabled = true) {}, shareLogsUM = TesterActionsContentState.ShareLogsUM(file = null), onBackClick = {}, ), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt index 5eeb879082..fb6e223f88 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt @@ -5,31 +5,25 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import arrow.core.getOrElse import com.tangem.data.common.account.WalletAccountsSaver -import com.tangem.domain.apptheme.ChangeAppThemeModeUseCase -import com.tangem.domain.apptheme.GetAppThemeModeUseCase -import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.HideAllCurrenciesUM -import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleAppThemeUM +import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleHotWalletRestrictionUM import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @HiltViewModel internal class TesterActionsViewModel @Inject constructor( - private val changeAppThemeModeUseCase: ChangeAppThemeModeUseCase, - private val getAppThemeModeUseCase: GetAppThemeModeUseCase, private val feedbackRepository: FeedbackRepository, private val userWalletsListRepository: UserWalletsListRepository, private val walletAccountsSaver: WalletAccountsSaver, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, ) : ViewModel() { var uiState: TesterActionsContentState by mutableStateOf(initialState) @@ -38,16 +32,16 @@ internal class TesterActionsViewModel @Inject constructor( private val initialState: TesterActionsContentState get() = TesterActionsContentState( hideAllCurrenciesUM = HideAllCurrenciesUM.Clickable(this::hideAllCurrencies), - toggleAppThemeUM = ToggleAppThemeUM( - currentAppTheme = AppThemeMode.DEFAULT, - onClick = this::toggleAppTheme, + toggleHotWalletRestrictionUM = ToggleHotWalletRestrictionUM( + isEnabled = hotWalletRestrictionManager.isCreationEnabledSync(), + onClick = this::toggleHotWalletRestriction, ), shareLogsUM = TesterActionsContentState.ShareLogsUM(file = feedbackRepository.getLogFile()), onBackClick = { /* no-op */ }, ) init { - bootstrapAppThemeModeUpdates() + bootstrapHotWalletRestrictionUpdates() } fun setupNavigation(router: InnerTesterRouter) { @@ -78,57 +72,16 @@ internal class TesterActionsViewModel @Inject constructor( ) } - private fun toggleAppTheme() = viewModelScope.launch { - val currentAppThemeMode = uiState.toggleAppThemeUM.currentAppTheme - val newAppThemeMode = when (currentAppThemeMode) { - AppThemeMode.FORCE_DARK -> AppThemeMode.FORCE_LIGHT - AppThemeMode.FORCE_LIGHT -> AppThemeMode.FOLLOW_SYSTEM - AppThemeMode.FOLLOW_SYSTEM -> AppThemeMode.FORCE_DARK - } - - TangemLogger.d( - """ - Change app theme mode - |- Current theme mode: $currentAppThemeMode - |- New theme mode: $newAppThemeMode - """.trimIndent(), - ) - - changeAppThemeModeUseCase(newAppThemeMode).onLeft { error -> - TangemLogger.e( - """ - Unable to change app theme mode - |- Error: $error - """.trimIndent(), - ) - } + private fun toggleHotWalletRestriction() = viewModelScope.launch { + hotWalletRestrictionManager.toggleCreationEnabled() } - private fun bootstrapAppThemeModeUpdates() { - getAppThemeModeUseCase() - .distinctUntilChanged() - .onEach { maybeAppThemeMode -> - TangemLogger.d( - """ - Current app theme mode updated - |- Previous app theme mode: ${uiState.toggleAppThemeUM.currentAppTheme} - |- New app theme mode: $maybeAppThemeMode - """.trimIndent(), - ) - + private fun bootstrapHotWalletRestrictionUpdates() { + hotWalletRestrictionManager.isCreationEnabled() + .onEach { isEnabled -> uiState = uiState.copy( - toggleAppThemeUM = uiState.toggleAppThemeUM.copy( - currentAppTheme = maybeAppThemeMode.getOrElse { error -> - TangemLogger.e( - """ - Unable to get current app theme mode, using default - |- Default theme mode: ${AppThemeMode.DEFAULT} - |- Error: $error - """.trimIndent(), - ) - - AppThemeMode.DEFAULT - }, + toggleHotWalletRestrictionUM = uiState.toggleHotWalletRestrictionUM.copy( + isEnabled = isEnabled, ), ) } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt index 3c2015978a..42f614a97d 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt @@ -13,7 +13,7 @@ import com.tangem.feature.tester.presentation.excludedblockchains.state.Blockcha import com.tangem.feature.tester.presentation.excludedblockchains.state.ExcludedBlockchainsScreenUM import com.tangem.feature.tester.presentation.excludedblockchains.state.mapper.toUiModels import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate @@ -25,7 +25,7 @@ import javax.inject.Inject @HiltViewModel internal class ExcludedBlockchainsViewModel @Inject constructor( - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, excludedBlockchainsManager: ExcludedBlockchainsManager, ) : ViewModel() { @@ -56,7 +56,7 @@ internal class ExcludedBlockchainsViewModel @Inject constructor( search = getInitialSearchBar(), blockchains = getBlockchains(), showRecoverWarning = !excludedBlockchainsManager.isMatchLocalConfig(), - appVersion = appVersionProvider.versionName, + appVersion = appInfoProvider.appVersion, onRestartClick = {}, onRecoverClick = ::recoverLocalConfig, ) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt index d27b8b8c7c..2e4df298b7 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt @@ -1,7 +1,7 @@ package com.tangem.feature.tester.presentation.excludedblockchains.state.mapper import com.tangem.blockchain.common.Blockchain -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.feature.tester.presentation.excludedblockchains.state.BlockchainUM internal fun List.toUiModels( @@ -25,7 +25,7 @@ private fun Blockchain.toUiModel(isExcluded: Boolean, onExcludedStateChange: (Bo id = id, name = name, symbol = currency, - iconResId = getActiveIconRes(id), + iconResId = getActiveIconRes(this), isExcluded = isExcluded, onExcludedStateChange = onExcludedStateChange, ) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt index 95338c3250..130a27e2ff 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt @@ -13,7 +13,7 @@ import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWit import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -25,14 +25,14 @@ import javax.inject.Inject * ViewModel for screen with list of feature toggles * * @property featureTogglesManager manager for getting information about the availability of feature toggles - * @property appVersionProvider app version provider + * @property appInfoProvider app info provider * [REDACTED_AUTHOR] */ @HiltViewModel internal class FeatureTogglesViewModel @Inject constructor( private val featureTogglesManager: FeatureTogglesManager, - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, ) : ViewModel() { /** Current ui state */ @@ -55,7 +55,7 @@ internal class FeatureTogglesViewModel @Inject constructor( private fun initState(): FeatureTogglesContentState { return FeatureTogglesContentState( topBar = getConfigSetupState(isPrimarySetup = true), - appVersion = appVersionProvider.versionName, + appVersion = appInfoProvider.appVersion, featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles(), onToggleValueChange = ::onToggleValueChange, onRestartAppClick = {}, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt index 744c5f95b3..71c83f80a2 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt @@ -34,7 +34,8 @@ import com.tangem.core.ui.components.TextInputDialog import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.rows.RowText -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -150,7 +151,7 @@ private fun BlockchainRow(state: ProvidersUM, onExpandStateChange: () -> Unit) { ) { Row(verticalAlignment = Alignment.CenterVertically) { Image( - painter = painterResource(id = getActiveIconRes(state.blockchainId)), + painter = painterResource(id = getActiveIconRes(Blockchain.fromId(state.blockchainId))), contentDescription = null, modifier = Modifier.size(TangemTheme.dimens.size36), ) 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 bf95156d7d..5bd49223ea 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 @@ -3,6 +3,7 @@ package com.tangem.feature.tester.presentation.storybook.entity 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 +import com.tangem.core.ui.ds.topbar.TangemTopBarType internal sealed interface StoryBookPage @@ -64,4 +65,22 @@ internal data class TangemSearchFieldStory( internal data class TypographyStory( val isFontScaleDefault: Boolean, val onFontScaleToggle: () -> Unit, -) : StoryBookPage \ No newline at end of file +) : StoryBookPage + +internal data class TangemTopBarStory( + val selectedType: TangemTopBarType, + val onTypeChange: (TangemTopBarType) -> Unit, +) : StoryBookPage + +internal data class TangemTabStory( + val checkedIndex: Int, + val onCheckedIndexChange: (Int) -> Unit, +) : StoryBookPage + +internal data object TangemPagerIndicatorStory : StoryBookPage + +internal data object PlaceholderStory : StoryBookPage + +internal data object ProgressIndicatorStory : StoryBookPage + +internal data object DeviceIconStory : StoryBookPage \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt index c3c6cea2a1..c158b5f7d1 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt @@ -139,12 +139,18 @@ private fun BadgeShapeGroup(size: TangemBadgeSize, shape: TangemBadgeShape, colo @Composable private fun ColumnHeaderRow() { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(STATE_LABEL_WIDTH.dp)) Text( - text = "Text + Icon", + text = "Icon Start", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.weight(1f), + ) + Text( + text = "Icon End", style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.weight(1f), @@ -172,7 +178,7 @@ private fun BadgeTypeRow( type: TangemBadgeType, ) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -196,6 +202,21 @@ private fun BadgeTypeRow( onClick = {}, ) } + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(1f), + ) { + TangemBadge( + text = stringReference("New"), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_information_24), + size = size, + shape = shape, + color = color, + type = type, + iconPosition = TangemBadgeIconPosition.End, + onClick = {}, + ) + } Box( contentAlignment = Alignment.CenterStart, modifier = Modifier.weight(1f), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt index d1bd85a0e6..f4c2e96d65 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt @@ -32,7 +32,7 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { .background(TangemTheme.colors2.surface.level1), ) { item("primary") { - ButtonSection(title = "Primary") { isEnabled, text, shape -> + ButtonSection(title = "Primary") { isEnabled, isLoading, text, shape, iconPosition -> PrimaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -46,14 +46,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("secondary") { - ButtonSection(title = "Secondary") { isEnabled, text, shape -> + ButtonSection(title = "Secondary") { isEnabled, isLoading, text, shape, iconPosition -> SecondaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -67,8 +69,10 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } @@ -77,7 +81,7 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { ButtonSection( title = "PrimaryInverse", background = TangemTheme.colors2.surface.level2, - ) { isEnabled, text, shape -> + ) { isEnabled, isLoading, text, shape, iconPosition -> PrimaryInverseTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -91,14 +95,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("outline") { - ButtonSection(title = "Outline") { isEnabled, text, shape -> + ButtonSection(title = "Outline") { isEnabled, isLoading, text, shape, iconPosition -> OutlineTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -112,14 +118,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("accent") { - ButtonSection(title = "Accent") { isEnabled, text, shape -> + ButtonSection(title = "Accent") { isEnabled, isLoading, text, shape, iconPosition -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -133,14 +141,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("positive") { - ButtonSection(title = "Positive") { isEnabled, text, shape -> + ButtonSection(title = "Positive") { isEnabled, isLoading, text, shape, iconPosition -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -154,15 +164,17 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, type = TangemButtonType.Positive, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("ghost") { - ButtonSection(title = "Ghost") { isEnabled, text, shape -> + ButtonSection(title = "Ghost") { isEnabled, isLoading, text, shape, iconPosition -> GhostTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -176,21 +188,74 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } + item("sizes") { + SizeShowcase() + } } } +@Composable +private fun SizeShowcase() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = "Sizes", + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + TangemButtonSize.entries.forEach { size -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = size.name, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.width(STATE_LABEL_WIDTH.dp), + ) + PrimaryTangemButton( + onClick = {}, + text = stringReference("Button"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_tangem_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primaryInverted }, + ), + size = size, + ) + } + } + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + @Composable private fun ButtonSection( title: String, background: Color = TangemTheme.colors2.surface.level1, shapes: List = TangemButtonShape.entries, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -205,7 +270,9 @@ private fun ButtonSection( color = TangemTheme.colors.text.primary1, ) shapes.forEach { shape -> - ShapeGroup(shape = shape, button = button) + TangemButtonIconPosition.entries.forEach { iconPosition -> + ShapeGroup(shape = shape, iconPosition = iconPosition, button = button) + } } } HorizontalDivider( @@ -217,17 +284,46 @@ private fun ButtonSection( @Composable private fun ShapeGroup( shape: TangemButtonShape, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + iconPosition: TangemButtonIconPosition, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = shape.name, + text = "${shape.name} / Icon ${iconPosition.name}", style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, ) ColumnHeaderRow() - StateRow(isEnabled = true, shape = shape, button = button) - StateRow(isEnabled = false, shape = shape, button = button) + StateRow( + label = "Enabled", + isEnabled = true, + isLoading = false, + shape = shape, + iconPosition = iconPosition, + button = button, + ) + StateRow( + label = "Disabled", + isEnabled = false, + isLoading = false, + shape = shape, + iconPosition = iconPosition, + button = button, + ) + StateRow( + label = "Loading", + isEnabled = true, + isLoading = true, + shape = shape, + iconPosition = iconPosition, + button = button, + ) } } @@ -253,27 +349,37 @@ private fun ColumnHeaderRow() { } } +@Suppress("LongParameterList") @Composable private fun StateRow( + label: String, isEnabled: Boolean, + isLoading: Boolean, shape: TangemButtonShape, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + iconPosition: TangemButtonIconPosition, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (isEnabled) "Enabled" else "Disabled", + text = label, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.width(STATE_LABEL_WIDTH.dp), ) Box(modifier = Modifier.weight(1f)) { - button(isEnabled, true, shape) + button(isEnabled, isLoading, true, shape, iconPosition) } Box(modifier = Modifier.weight(1f)) { - button(isEnabled, false, shape) + button(isEnabled, isLoading, false, shape, iconPosition) } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt new file mode 100644 index 0000000000..aaa3b8fc51 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.deviceicon + +import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val deviceIconStoryFactory: StoryPageFactory = + StoryPageFactory { DeviceIconStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt new file mode 100644 index 0000000000..0c3708b35d --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt @@ -0,0 +1,184 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.deviceicon + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.res.TangemTheme + +private val CardBlue = Color(0xFF1C5FBF) +private val CardGold = Color(0xFFD4A017) +private val CardPurple = Color(0xFF7B2FBE) +private val RingGreen = Color(0xFF2ECC71) + +@Composable +internal fun DeviceIconStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_cards") { + SectionTitle(text = "Cards") + } + + item("card_1") { + DeviceIconRow( + label = "Single card", + state = DeviceIconUM.Card(mainColor = CardBlue, secondColor = null), + ) + } + + item("card_2") { + DeviceIconRow( + label = "Two cards", + state = DeviceIconUM.Card(mainColor = CardBlue, secondColor = CardGold), + ) + } + + item("card_3") { + DeviceIconRow( + label = "Three cards", + state = DeviceIconUM.Card( + mainColor = CardBlue, + secondColor = CardGold, + thirdColor = CardPurple, + ), + ) + } + + item("section_rings") { + SectionTitle(text = "Rings") + } + + item("ring_solo") { + DeviceIconRow( + label = "Ring only", + state = DeviceIconUM.Ring(mainColor = RingGreen), + ) + } + + item("ring_card") { + DeviceIconRow( + label = "Ring + card", + state = DeviceIconUM.Ring(mainColor = RingGreen, cardColor = CardBlue), + ) + } + + item("ring_two_cards") { + DeviceIconRow( + label = "Ring + 2 cards", + state = DeviceIconUM.Ring( + mainColor = RingGreen, + cardColor = CardBlue, + secondCardColor = CardGold, + ), + ) + } + + item("section_stubs") { + SectionTitle(text = "Stubs") + } + + repeat(3) { count -> + item("stub_$count") { + DeviceIconRow( + label = "Stub ($count card${if (count > 0) "s" else ""})", + state = DeviceIconUM.Stub(cardsCount = count), + ) + } + } + + item("section_mobile") { + SectionTitle(text = "Mobile") + } + + item("mobile") { + DeviceIconRow( + label = "Mobile wallet", + state = DeviceIconUM.Mobile, + ) + } + + item("section_sizes") { + SectionTitle(text = "Sizes") + } + + item("sizes") { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Bottom, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + listOf(24, 32, 40, 48).forEach { size -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TangemDeviceIcon( + state = DeviceIconUM.Card( + mainColor = CardBlue, + secondColor = CardGold, + thirdColor = CardPurple, + ), + modifier = Modifier.size(size.dp), + ) + Text( + text = "${size}dp", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun DeviceIconRow(label: String, state: DeviceIconUM) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + TangemDeviceIcon( + state = state, + modifier = Modifier.size(40.dp), + ) + Text( + text = label, + style = TangemTheme.typography.body2, + color = 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/headerrow/TangemHeaderRowStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt index 95a5c349d7..f034c7b02f 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt @@ -88,6 +88,29 @@ private fun buildSampleRows(): List = listOf( title = stringReference("Account"), subtitle = stringReference("\$ 42,900.17"), ), + TangemHeaderRowUM( + id = "tail_text", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Text(text = stringReference("12 tokens")), + title = stringReference("Account"), + subtitle = stringReference("\$ 42,900.17"), + ), + TangemHeaderRowUM( + id = "tail_draggable", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Draggable(iconRes = R.drawable.ic_drag_24), + title = stringReference("Account"), + subtitle = stringReference("\$ 42,900.17"), + ), + TangemHeaderRowUM( + id = "clickable", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Icon(R.drawable.ic_arrow_collapse_24), + title = stringReference("Clickable row"), + subtitle = stringReference("\$ 42,900.17"), + isEnabled = true, + onItemClick = {}, + ), TangemHeaderRowUM( id = "title_only", title = stringReference("Account"), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt new file mode 100644 index 0000000000..22832e4de4 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.pagerindicator + +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory +import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory + +internal val tangemPagerIndicatorStoryFactory: StoryPageFactory = + StoryPageFactory { TangemPagerIndicatorStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt new file mode 100644 index 0000000000..fc770c4015 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt @@ -0,0 +1,142 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.pagerindicator + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.TangemPagerIndicator +import com.tangem.core.ui.ds.TangemPagerIndicatorColors +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun TangemPagerIndicatorStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_page_counts") { + SectionTitle(text = "Page counts") + } + + listOf(1, 2, 3, 4, 5).forEach { pageCount -> + item("count_$pageCount") { + IndicatorRow(label = "$pageCount page(s)", pageCount = pageCount, currentPage = 0) + } + } + + item("section_many_pages") { + SectionTitle(text = "Many pages (>5)") + } + + item("many_start") { + IndicatorRow(label = "7 pages, at start", pageCount = 7, currentPage = 0) + } + + item("many_middle") { + IndicatorRow(label = "7 pages, at middle", pageCount = 7, currentPage = 3) + } + + item("many_end") { + IndicatorRow(label = "7 pages, at end", pageCount = 7, currentPage = 6) + } + + item("ten_start") { + IndicatorRow(label = "10 pages, at start", pageCount = 10, currentPage = 0) + } + + item("ten_middle") { + IndicatorRow(label = "10 pages, at middle", pageCount = 10, currentPage = 5) + } + + item("ten_end") { + IndicatorRow(label = "10 pages, at end", pageCount = 10, currentPage = 9) + } + + item("section_active_positions") { + SectionTitle(text = "Active dot positions (5 pages)") + } + + repeat(4) { page -> + item("active_$page") { + IndicatorRow(label = "Active: page ${page + 1}", pageCount = 5, currentPage = page) + } + } + + item("section_overlay") { + SectionTitle(text = "With overlay background") + } + + item("overlay") { + IndicatorRowWithOverlay(pageCount = 5, currentPage = 2) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun IndicatorRow(label: String, pageCount: Int, currentPage: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemPagerIndicator( + pagerState = rememberPagerState(currentPage) { pageCount }, + ) + } +} + +@Composable +private fun IndicatorRowWithOverlay(pageCount: Int, currentPage: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = "With overlay", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemPagerIndicator( + pagerState = rememberPagerState(currentPage) { pageCount }, + colors = TangemPagerIndicatorColors.copy( + overlay = TangemTheme.colors2.tabs.backgroundSecondary, + ), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt new file mode 100644 index 0000000000..947666e5a7 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.placeholder + +import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val placeholderStoryFactory: StoryPageFactory = + StoryPageFactory { PlaceholderStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt new file mode 100644 index 0000000000..5eb7aa7d41 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt @@ -0,0 +1,254 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.placeholder + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.ChipShimmer +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun PlaceholderStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_rectangle") { + SectionTitle(text = "RectangleShimmer") + } + + item("rect_default") { + ShimmerRow(label = "Default") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + ) + } + } + + item("rect_narrow") { + ShimmerRow(label = "Narrow (40%)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth(fraction = 0.4f) + .height(16.dp), + ) + } + } + + item("rect_tall") { + ShimmerRow(label = "Tall (48dp)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + ) + } + } + + item("rect_custom_radius") { + ShimmerRow(label = "Custom radius (16dp)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + radius = 16.dp, + ) + } + } + + item("section_circle") { + SectionTitle(text = "CircleShimmer") + } + + item("circle_sizes") { + ShimmerRow(label = "Sizes: 24, 32, 40, 48") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircleShimmer(modifier = Modifier.size(24.dp)) + CircleShimmer(modifier = Modifier.size(32.dp)) + CircleShimmer(modifier = Modifier.size(40.dp)) + CircleShimmer(modifier = Modifier.size(48.dp)) + } + } + } + + item("section_text") { + SectionTitle(text = "TextShimmer") + } + + item("text_title") { + ShimmerRow(label = "titleRegular44") { + TextShimmer( + style = TangemTheme.typography2.titleRegular44, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + ) + } + } + + item("text_heading") { + ShimmerRow(label = "headingSemibold22") { + TextShimmer( + style = TangemTheme.typography2.headingSemibold22, + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + ) + } + } + + item("text_body") { + ShimmerRow(label = "bodyRegular16") { + TextShimmer( + style = TangemTheme.typography2.bodyRegular16, + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + ) + } + } + + item("text_caption") { + ShimmerRow(label = "captionRegular12") { + TextShimmer( + style = TangemTheme.typography2.captionRegular12, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + } + } + + item("text_size_height") { + ShimmerRow(label = "bodyRegular16 (textSizeHeight)") { + TextShimmer( + style = TangemTheme.typography2.bodyRegular16, + textSizeHeight = true, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + ) + } + } + + item("section_button_chip") { + SectionTitle(text = "SmallButtonShimmer & ChipShimmer") + } + + item("small_button") { + ShimmerRow(label = "SmallButtonShimmer") { + SmallButtonShimmer() + } + } + + item("small_button_icon") { + ShimmerRow(label = "SmallButtonShimmer (with icon)") { + SmallButtonShimmer(withIcon = true) + } + } + + item("chip") { + ShimmerRow(label = "ChipShimmer") { + ChipShimmer() + } + } + + item("section_composition") { + SectionTitle(text = "Skeleton composition") + } + + item("card_skeleton") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "Typical card loading state", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircleShimmer(modifier = Modifier.size(40.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextShimmer( + style = TangemTheme.typography2.headingSemibold17, + modifier = Modifier.width(120.dp), + ) + TextShimmer( + style = TangemTheme.typography2.captionRegular13, + modifier = Modifier.width(80.dp), + ) + } + } + } + } + + item("list_skeleton") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "List loading state", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + repeat(3) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RectangleShimmer(modifier = Modifier.size(40.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + TextShimmer( + style = TangemTheme.typography2.bodyMedium16, + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + ) + TextShimmer( + style = TangemTheme.typography2.captionRegular13, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + } + } + } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun ShimmerRow(label: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + content() + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt new file mode 100644 index 0000000000..9855967b04 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.progress + +import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val progressIndicatorStoryFactory: StoryPageFactory = + StoryPageFactory { ProgressIndicatorStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt new file mode 100644 index 0000000000..85ef01a6ea --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt @@ -0,0 +1,132 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.progress + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.progress.TangemLinearProgressIndicatorWithDot +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun ProgressIndicatorStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_progress") { + SectionTitle(text = "Progress values") + } + + listOf(0f, 0.25f, 0.5f, 0.75f, 1f).forEach { progress -> + item("progress_$progress") { + ProgressRow(label = "${(progress * 100).toInt()}%", progress = progress) + } + } + + item("section_heights") { + SectionTitle(text = "Track heights") + } + + listOf(4, 6, 8).forEach { height -> + item("height_$height") { + ProgressRow(label = "${height}dp track", progress = 0.5f, height = height) + } + } + + item("section_colors") { + SectionTitle(text = "Color variants") + } + + item("accent") { + ProgressRowWithColors( + label = "Accent", + progress = 0.6f, + dotColor = TangemTheme.colors2.fill.status.accent, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + + item("warning") { + ProgressRowWithColors( + label = "Warning", + progress = 0.4f, + dotColor = TangemTheme.colors2.fill.status.warning, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + + item("attention") { + ProgressRowWithColors( + label = "Attention", + progress = 0.8f, + dotColor = TangemTheme.colors2.fill.status.attention, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun ProgressRow(label: String, progress: Float, height: Int = 6) { + ProgressRowWithColors( + label = label, + progress = progress, + height = height, + dotColor = TangemTheme.colors2.fill.status.accent, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) +} + +@Composable +private fun ProgressRowWithColors( + label: String, + progress: Float, + dotColor: androidx.compose.ui.graphics.Color, + bgColor: androidx.compose.ui.graphics.Color, + height: Int = 6, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemLinearProgressIndicatorWithDot( + progress = { progress }, + dotColor = dotColor, + backgroundColor = bgColor, + modifier = Modifier + .fillMaxWidth() + .height(height.dp), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt new file mode 100644 index 0000000000..21b9a31e14 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt @@ -0,0 +1,19 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.tab + +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemTabStory { + return TangemTabStory( + checkedIndex = 0, + onCheckedIndexChange = { index -> + updateStory { it.copy(checkedIndex = index) } + }, + ) +} + +internal val tangemTabStoryFactory + 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/tab/TangemTabStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt new file mode 100644 index 0000000000..f734c1027c --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt @@ -0,0 +1,127 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.tab + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.tabs.TangemTab +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory + +@Composable +internal fun TangemTabStory(state: TangemTabStory, modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("interactive") { + TabSection(title = "Interactive") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("Markets", "Portfolio", "Activity").forEachIndexed { index, label -> + TangemTab( + text = stringReference(label), + isChecked = state.checkedIndex == index, + onCheckedChange = { if (it) state.onCheckedIndexChange(index) }, + ) + } + } + } + } + + item("checked") { + TabSection(title = "Checked") { + TangemTab( + text = stringReference("Markets"), + isChecked = true, + onCheckedChange = {}, + ) + } + } + + item("unchecked") { + TabSection(title = "Unchecked") { + TangemTab( + text = stringReference("Markets"), + isChecked = false, + onCheckedChange = {}, + ) + } + } + + item("disabled_checked") { + TabSection(title = "Disabled (Checked)") { + TangemTab( + text = stringReference("Markets"), + isChecked = true, + onCheckedChange = {}, + isEnabled = false, + ) + } + } + + item("disabled_unchecked") { + TabSection(title = "Disabled (Unchecked)") { + TangemTab( + text = stringReference("Markets"), + isChecked = false, + onCheckedChange = {}, + isEnabled = false, + ) + } + } + + item("multiple_tabs") { + TabSection(title = "Multiple tabs row") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TangemTab( + text = stringReference("All"), + isChecked = true, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Gainers"), + isChecked = false, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Losers"), + isChecked = false, + onCheckedChange = {}, + ) + } + } + } + } +} + +@Composable +private fun TabSection(title: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt new file mode 100644 index 0000000000..494ab36f6b --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt @@ -0,0 +1,20 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.topbar + +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemTopBarStory { + return TangemTopBarStory( + selectedType = TangemTopBarType.Default, + onTypeChange = { type -> + updateStory { it.copy(selectedType = type) } + }, + ) +} + +internal val tangemTopBarStoryFactory + 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/topbar/TangemTopBarStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt new file mode 100644 index 0000000000..4a0d026c91 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt @@ -0,0 +1,259 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.topbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemTopBarStory(state: TangemTopBarStory, modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + stickyHeader("type_toggle") { + TypeToggle( + selected = state.selectedType, + onSelect = state.onTypeChange, + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + item("title_only") { + TopBarVariant(label = "Title only") { + TangemTopBar( + title = stringReference("Wallet"), + type = state.selectedType, + startAction = null, + ) + } + } + + item("title_subtitle") { + TopBarVariant(label = "Title + Subtitle") { + TangemTopBar( + title = stringReference("Wallet"), + subtitle = stringReference("3 cards"), + type = state.selectedType, + startAction = null, + ) + } + } + + item("back_action") { + TopBarVariant(label = "Back action + Title") { + TangemTopBar( + title = stringReference("Send"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + ) + } + } + + item("back_and_end") { + TopBarVariant(label = "Back + Title + End action") { + TangemTopBar( + title = stringReference("Token Details"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + onClick = {}, + ), + ), + ) + } + } + + item("back_and_two_end") { + TopBarVariant(label = "Back + Title + 2 End actions") { + TangemTopBar( + title = stringReference("Settings"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_information_24, + onClick = {}, + ), + TangemTopBarActionUM( + iconRes = R.drawable.ic_close_24, + onClick = {}, + ), + ), + ) + } + } + + item("ghost_actions") { + TopBarVariant(label = "Ghost mode actions (progress=1)") { + TangemTopBar( + title = stringReference("Portfolio"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_tangem_24, + onClick = {}, + ghostModeProgress = 1f, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + onClick = {}, + ghostModeProgress = 1f, + ), + ), + ) + } + } + + item("non_actionable") { + TopBarVariant(label = "Non-actionable icons") { + TangemTopBar( + title = stringReference("Details"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_tangem_24, + isActionable = false, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + isActionable = false, + ), + ), + ) + } + } + + item("title_icon") { + TopBarVariant(label = "Title with icon") { + TangemTopBar( + title = stringReference("Wallet"), + titleIconRes = R.drawable.ic_tangem_24, + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + ) + } + } + + item("no_title") { + TopBarVariant(label = "No title (end action only)") { + TangemTopBar( + type = state.selectedType, + startAction = null, + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_close_24, + onClick = {}, + ), + ), + ) + } + } + } +} + +@Composable +private fun TypeToggle( + selected: TangemTopBarType, + onSelect: (TangemTopBarType) -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(50) + Row( + modifier = modifier + .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), + ) { + TangemTopBarType.entries.forEach { type -> + TypeChip( + label = type.name, + selected = type == selected, + onClick = { onSelect(type) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun TypeChip(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 TopBarVariant(label: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + content() + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt index 3a8b75942d..26d7f561c6 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt @@ -20,12 +20,18 @@ import com.tangem.feature.tester.presentation.storybook.page.badge.tangemBadgeSt import com.tangem.feature.tester.presentation.storybook.page.buttons.buttonsStoryFactory import com.tangem.feature.tester.presentation.storybook.page.checkbox.tangemCheckboxStoryFactory import com.tangem.feature.tester.presentation.storybook.page.contextmenu.tangemContextMenuStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.deviceicon.deviceIconStoryFactory import com.tangem.feature.tester.presentation.storybook.page.headerrow.tangemHeaderRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.message.tangemMessageStoryFactory import com.tangem.feature.tester.presentation.storybook.page.opportunities.opportunitiesBGStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.pagerindicator.tangemPagerIndicatorStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.placeholder.placeholderStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.progress.progressIndicatorStoryFactory import com.tangem.feature.tester.presentation.storybook.page.searchfield.tangemSearchFieldStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.tab.tangemTabStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tabs.tangemSegmentedPickerStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tokenrow.tangemTokenRowStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.topbar.tangemTopBarStoryFactory import com.tangem.feature.tester.presentation.storybook.page.typography.typographyStoryFactory private data class StoryItem(val title: String, val factory: StoryPageFactory) @@ -43,6 +49,12 @@ private fun buildStories() = listOf( StoryItem(title = "📋 Context Menu", factory = tangemContextMenuStoryFactory), StoryItem(title = "🔍 Search Field", factory = tangemSearchFieldStoryFactory), StoryItem(title = "🔤 Typography", factory = typographyStoryFactory), + StoryItem(title = "🧭 Top Bar", factory = tangemTopBarStoryFactory), + StoryItem(title = "🔀 Tab", factory = tangemTabStoryFactory), + StoryItem(title = "⚫ Pager Indicator", factory = tangemPagerIndicatorStoryFactory), + StoryItem(title = "💀 Placeholder", factory = placeholderStoryFactory), + StoryItem(title = "⏳ Progress Indicator", factory = progressIndicatorStoryFactory), + StoryItem(title = "💳 Device Icon", factory = deviceIconStoryFactory), ) @Composable 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 e8e17c0645..7bce52bb28 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 @@ -5,8 +5,11 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.feature.tester.presentation.storybook.entity.ButtonsStory +import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory import com.tangem.feature.tester.presentation.storybook.entity.NorthernLightsStory import com.tangem.feature.tester.presentation.storybook.entity.OpportunitiesBGStory +import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeStory import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM import com.tangem.feature.tester.presentation.storybook.entity.StoryList @@ -14,23 +17,33 @@ import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxSto import com.tangem.feature.tester.presentation.storybook.entity.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory import com.tangem.feature.tester.presentation.storybook.entity.TypographyStory import com.tangem.feature.tester.presentation.storybook.page.background.NorthernLightsStory import com.tangem.feature.tester.presentation.storybook.page.badge.TangemBadgeStory import com.tangem.feature.tester.presentation.storybook.page.buttons.ButtonsStory +import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIconStory import com.tangem.feature.tester.presentation.storybook.page.opportunities.OpportunitiesBGStory import com.tangem.feature.tester.presentation.storybook.page.checkbox.TangemCheckboxStory import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.page.pagerindicator.TangemPagerIndicatorStory +import com.tangem.feature.tester.presentation.storybook.page.placeholder.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.page.progress.ProgressIndicatorStory +import com.tangem.feature.tester.presentation.storybook.page.tab.TangemTabStory import com.tangem.feature.tester.presentation.storybook.page.tabs.TangemSegmentedPickerStory import com.tangem.feature.tester.presentation.storybook.page.tokenrow.TangemTokenRowStory +import com.tangem.feature.tester.presentation.storybook.page.topbar.TangemTopBarStory import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.page.contextmenu.TangemContextMenuStory import com.tangem.feature.tester.presentation.storybook.page.searchfield.TangemSearchFieldStory import com.tangem.feature.tester.presentation.storybook.page.typography.TypographyStory +@Suppress("CyclomaticComplexMethod") @Composable internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) { BackHandler(onBack = state.onBackClick) @@ -54,6 +67,12 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemContextMenuStory -> TangemContextMenuStory(state = storyState) is TangemSearchFieldStory -> TangemSearchFieldStory(state = storyState) is TypographyStory -> TypographyStory(state = storyState) + is TangemTopBarStory -> TangemTopBarStory(state = storyState) + is TangemTabStory -> TangemTabStory(state = storyState) + TangemPagerIndicatorStory -> TangemPagerIndicatorStory() + PlaceholderStory -> PlaceholderStory() + ProgressIndicatorStory -> ProgressIndicatorStory() + DeviceIconStory -> DeviceIconStory() } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/res/values/strings.xml b/features/tester/impl/src/main/res/values/strings.xml index f6d9e5cf50..5edc7e890f 100644 --- a/features/tester/impl/src/main/res/values/strings.xml +++ b/features/tester/impl/src/main/res/values/strings.xml @@ -11,10 +11,10 @@ Environment toggles Tester actions Hide all currencies - Toggle app theme - %s Excluded blockchains Filter by name or symbol Blockchain providers + Hot wallet creation restriction - %s Share logs Test push Accounts diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt index b5264d8402..1a65fcfde1 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt @@ -19,6 +19,8 @@ internal data class ReceiveAddress( data class Default(override val displayName: TextReference) : Primary data class Legacy(override val displayName: TextReference) : Primary + + data class Dynamic(override val displayName: TextReference) : Primary } } } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt index bcd955a4af..401e005148 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt @@ -4,8 +4,9 @@ import androidx.compose.ui.graphics.Color import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.R import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.* import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveNotification @@ -20,6 +21,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList +@Suppress("MagicNumber") internal class TokenReceiveStateFactory( private val currentStateProvider: Provider, private val cryptoCurrency: CryptoCurrency, @@ -66,8 +68,9 @@ internal class TokenReceiveStateFactory( compareBy { address -> when (address.type) { is Ens -> 0 - is Primary.Default -> 1 - is Primary.Legacy -> 2 + is Primary.Dynamic -> 1 + is Primary.Default -> 2 + is Primary.Legacy -> 3 } }, ) @@ -82,32 +85,18 @@ internal class TokenReceiveStateFactory( addresses: List, cryptoCurrency: CryptoCurrency, ): ImmutableList { - val needUseToLegacyAndDefaultName = addresses.any { it.nameService == ReceiveAddressModel.NameService.Legacy } + val shouldUseToLegacyAndDefaultName = addresses.any { it.displayType == ReceiveAddressModel.DisplayType.Legacy } val receiveAddresses = addresses.map { model -> - val type = when (model.nameService) { - ReceiveAddressModel.NameService.Default -> { - val displayName = when (cryptoCurrency) { - is CryptoCurrency.Coin -> cryptoCurrency.name - is CryptoCurrency.Token -> cryptoCurrency.symbol - } - - Primary.Default( - displayName = if (needUseToLegacyAndDefaultName) { - TextReference.Res(R.string.domain_receive_assets_default_address) - } else { - TextReference.Combined( - wrappedList( - TextReference.Str(displayName), - TextReference.Str(" "), - TextReference.Res(R.string.common_address), - ), - ) - }, - ) + val type = when (model.displayType) { + ReceiveAddressModel.DisplayType.Default -> { + Primary.Default(displayName = defaultDisplayName(cryptoCurrency, shouldUseToLegacyAndDefaultName)) } - ReceiveAddressModel.NameService.Ens -> Ens - ReceiveAddressModel.NameService.Legacy -> Primary.Legacy( + ReceiveAddressModel.DisplayType.Dynamic -> { + Primary.Dynamic(displayName = coinAddressDisplayName(cryptoCurrency)) + } + ReceiveAddressModel.DisplayType.Ens -> Ens + ReceiveAddressModel.DisplayType.Legacy -> Primary.Legacy( displayName = resourceReference( R.string.domain_receive_assets_legacy_address, WrappedList(listOf(cryptoCurrency.name)), @@ -124,8 +113,9 @@ internal class TokenReceiveStateFactory( compareBy { address -> when (address.type) { is Ens -> 0 - is Primary.Default -> 1 - is Primary.Legacy -> 2 + is Primary.Dynamic -> 1 + is Primary.Default -> 2 + is Primary.Legacy -> 3 } }, ) @@ -170,4 +160,29 @@ internal class TokenReceiveStateFactory( isGrayscale = false, shouldShowCustomBadge = false, ) + + private fun defaultDisplayName( + cryptoCurrency: CryptoCurrency, + needUseToLegacyAndDefaultName: Boolean, + ): TextReference { + return if (needUseToLegacyAndDefaultName) { + TextReference.Res(R.string.domain_receive_assets_default_address) + } else { + coinAddressDisplayName(cryptoCurrency) + } + } + + private fun coinAddressDisplayName(cryptoCurrency: CryptoCurrency): TextReference { + val displayName = when (cryptoCurrency) { + is CryptoCurrency.Coin -> cryptoCurrency.name + is CryptoCurrency.Token -> cryptoCurrency.symbol + } + return TextReference.Combined( + wrappedList( + TextReference.Str(displayName), + TextReference.Str(" "), + TextReference.Res(R.string.common_address), + ), + ) + } } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt index 63202e22b0..61602b31b4 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -300,6 +301,7 @@ private fun PrimaryAddressesItems( onShareClick = { onShareClick(selectedAddress.value) }, primaryType = selectedAddress.type as ReceiveAddress.Type.Primary, address = selectedAddress.value, + isDynamicAddress = selectedAddress.type is ReceiveAddress.Type.Primary.Dynamic, snackbarHostState = snackbarHostState, ) } @@ -345,6 +347,7 @@ private fun AddressItem( onShareClick: () -> Unit, primaryType: ReceiveAddress.Type.Primary, address: String, + isDynamicAddress: Boolean, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, ) { @@ -369,6 +372,11 @@ private fun AddressItem( SpacerH(12.dp) + if (isDynamicAddress) { + DynamicAddressBadge() + SpacerH(8.dp) + } + Text( text = primaryType.displayName.resolveReference(), color = TangemTheme.colors.text.primary1, @@ -564,6 +572,34 @@ private fun ActionButtonWithResizableText(config: ActionButtonConfig, modifier: ) } +@Composable +private fun DynamicAddressBadge(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background( + color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), + shape = RoundedCornerShape(percent = 50), + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painterResource( + id = R.drawable.ic_dynamic_addresses_badge_16, + ), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = TangemTheme.colors.icon.accent, + ) + Text( + text = stringResourceSafe(R.string.dynamic_addresses_receive_badge), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.accent, + ) + } +} + @Composable @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 51a9203ba8..d538fa28d5 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -11,6 +11,9 @@ android { namespace = "com.tangem.features.tokendetails.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} dependencies { /** AndroidX */ @@ -34,7 +37,6 @@ dependencies { implementation(deps.arrow.core) implementation(deps.jodatime) implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) implementation(tangemDeps.blockchain) implementation(tangemDeps.card.core) implementation(deps.lifecycle.compose) @@ -68,6 +70,8 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.card) implementation(projects.domain.demo) + implementation(projects.domain.dynamicAddresses) + implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.markets.models) implementation(projects.domain.models) implementation(projects.domain.notifications.models) @@ -107,8 +111,13 @@ dependencies { implementation(projects.features.sendV2.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.yieldSupply.api) - implementation(projects.features.tangempay.details.api) implementation(deps.decompose.ext.compose) + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index a8f037db6d..14d9bddff3 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -24,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -97,6 +98,8 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( TokenDetailsScreen( tokenDetailsUM = tokenDetailsUM, tokenMarketBlockComponent = tokenMarketBlockComponent, + yieldSupplyComponent = yieldSupplyComponent, + txHistoryComponent = txHistoryComponent, modifier = modifier, ) } else { @@ -148,6 +151,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( cloreMigrationModel = model.cloreMigrationModel, onDismiss = model.bottomSheetNavigation::dismiss, ) + is TokenDetailsBottomSheetConfig.DynamicAddresses -> DynamicAddressesBottomSheetComponent( + dynamicAddressesDelegate = model.dynamicAddressesDelegate, + onDismiss = model.bottomSheetNavigation::dismiss, + ) } @AssistedFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index 53c5cd58ec..e9ee88d2bd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -9,8 +9,8 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -18,21 +18,19 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.notifications.models.NotificationType -import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger @Suppress("LongParameterList") internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( @@ -48,7 +46,6 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val walletBalanceFetcher: WalletBalanceFetcher, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val singleAccountListSupplier: SingleAccountListSupplier, ) : TokenDetailsDeepLinkHandler { @@ -128,19 +125,13 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) { val isMultiCurrency = userWallet.isMultiCurrency - // single-currency wallet with token (NODL) - userWallet is UserWallet.Cold && - userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() when { isMultiCurrency -> cryptoCurrencyBalanceFetcher( userWalletId = userWallet.walletId, currency = cryptoCurrency, ) !isMultiCurrency -> walletBalanceFetcher( - params = WalletBalanceFetcher.Params( - userWalletId = userWallet.walletId, - isPaymentAccountRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, - ), + params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId), ) } } @@ -150,7 +141,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( val derivationPath = queryParams[DERIVATION_PATH_KEY] getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency -> - val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true) + val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true) val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt index 1c8370811a..15664feeef 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt @@ -4,7 +4,9 @@ import com.tangem.blockchainsdk.utils.isNeedToCreateAccountWithoutReserve import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -34,6 +36,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor( private val currencyChecksRepository: CurrencyChecksRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val dynamicAddressesRepository: DynamicAddressesRepository, ) { suspend operator fun invoke( @@ -52,7 +55,12 @@ internal class GetCurrencyWarningsUseCase @Inject constructor( flow2 = flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)), flow3 = flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)), flow4 = flowOf(currencyChecksRepository.getFeeResourceAmount(userWalletId, currency.network)), - ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource -> + flow5 = if (currency is CryptoCurrency.Coin) { + dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, currency.network) + } else { + flowOf(false) + }, + ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource, hasExtraFunds -> setOfNotNull( maybeRentWarning, maybeEdWarning?.let { getExistentialDepositWarning(currency, it) }, @@ -64,6 +72,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor( getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency), getMigrationFromMaticToPolWarning(currency), getCloreMigrationWarning(currency), + DynamicAddressesWarnings.FundsFound.takeIf { hasExtraFunds }, ) }.flowOn(dispatchers.io) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index d65921fdb9..ba37caff40 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -62,6 +62,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( is TokenDetailsNotification.MigrationClore, is TokenDetailsNotification.UsedOutdatedData, -> null + is TokenDetailsNotification.DynamicAddressesFundsFound -> null // TODO: [REDACTED_TASK_KEY] analytics event } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt new file mode 100644 index 0000000000..63f7200f14 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -0,0 +1,362 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import com.tangem.common.core.TangemSdkError +import com.tangem.core.decompose.di.GlobalUiMessageSender +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.common.ui.amountScreen.utils.getFiatString +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase +import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.Provider +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class DynamicAddressesDelegate @AssistedInject constructor( + private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, + private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase, + private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase, + private val getFeeUseCase: GetFeeUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val getDerivedXpubUseCase: GetDerivedXpubUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, + private val dispatchers: CoroutineDispatcherProvider, + @Assisted private val userWallet: UserWallet, + @Assisted private val cryptoCurrencyStatusProvider: Provider, + @Assisted private val appCurrencyProvider: Provider, + @Assisted private val coroutineScope: CoroutineScope, + @Assisted("showBottomSheet") private val showBottomSheet: () -> Unit, + @Assisted("dismissBottomSheet") private val dismissBottomSheet: () -> Unit, + @Assisted("onDynamicAddressesStateChanged") private val onDynamicAddressesStateChanged: () -> Unit, +) { + + private val userWalletId get() = userWallet.walletId + + private val _bottomSheetConfig = MutableStateFlow( + DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + onEnableClick = {}, + ), + ) + val bottomSheetConfig: StateFlow = _bottomSheetConfig.asStateFlow() + + // region Entry point + + fun onDynamicAddressesClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + coroutineScope.launch(dispatchers.main) { + val status = dynamicAddressesRepository.getStatus(userWalletId, network).first() + when (status) { + DynamicAddressesStatus.ENABLED, + DynamicAddressesStatus.ENABLED_REQUIRES_SETUP, + -> onDisableFlow(network) + DynamicAddressesStatus.DISABLED -> onEnableFlow(network) + } + } + } + + // endregion + + // region Enable flow + + private suspend fun onEnableFlow(network: Network) { + val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) + if (hasConflicts) { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( + onDismissClick = dismissBottomSheet, + ) + showBottomSheet() + return + } + + val isCardScanRequired = !isXpubAlreadyDerived(network) + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = isCardScanRequired, + onEnableClick = ::onEnableClick, + ) + showBottomSheet() + } + + private fun onEnableClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + coroutineScope.launch(dispatchers.main) { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + isLoading = true, + onEnableClick = {}, + ) + + val xpub = getExtendedPublicKeyUseCase(userWalletId, network).fold( + ifLeft = { error -> + if (isUserCancellation(error)) { + dismissBottomSheet() + } else { + TangemLogger.e("Failed to get XPUB: ${error.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + } + return@launch + }, + ifRight = { it }, + ) + + enableDynamicAddressesUseCase(userWalletId, network, xpub).fold( + ifLeft = { error -> + when (error) { + is EnableDynamicAddressesError.ConflictingCustomTokens -> { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( + onDismissClick = dismissBottomSheet, + ) + } + is EnableDynamicAddressesError.ServiceError -> { + TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + } + } + }, + ifRight = { + dismissBottomSheet() + onDynamicAddressesStateChanged() + uiMessageSender.send( + SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)), + ) + }, + ) + } + } + + // endregion + + // region Disable flow + + private fun onDisableFlow(network: Network) { + coroutineScope.launch(dispatchers.main) { + disableDynamicAddressesUseCase(userWalletId, network).fold( + ifLeft = { error -> + TangemLogger.e("Failed to check disable: ${error.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + showBottomSheet() + }, + ifRight = { isConsolidationRequired -> + if (!isConsolidationRequired) { + showSimpleDisableSheet() + } else { + showDisableSheetAndLoadFee() + } + }, + ) + } + } + + private fun showSimpleDisableSheet() { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation( + onDisableClick = ::onSimpleDisableClick, + onReadMoreClick = ::onReadMoreClick, + ) + showBottomSheet() + } + + private fun onSimpleDisableClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + coroutineScope.launch(dispatchers.main) { + runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) } + .onSuccess { + dismissBottomSheet() + onDynamicAddressesStateChanged() + uiMessageSender.send( + SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_disabled_popup_title)), + ) + } + .onFailure { e -> + TangemLogger.e("Failed to disable dynamic addresses: ${e.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + } + } + } + + private fun showDisableSheetAndLoadFee() { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading, + onDisableClick = ::onDisableClick, + onRefreshFee = ::loadDisableFee, + onReadMoreClick = ::onReadMoreClick, + ) + showBottomSheet() + loadDisableFee() + } + + private fun loadDisableFee() { + coroutineScope.launch(dispatchers.main) { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading, + ) + + val status = cryptoCurrencyStatusProvider() + val currency = status?.currency + val balance = status?.value?.amount + val address = status?.value?.networkAddress?.defaultAddress?.value + + if (currency == null || balance == null || address == null) { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error, + ) + return@launch + } + + getFeeUseCase( + amount = balance, + destination = address, + userWallet = userWallet, + cryptoCurrency = currency, + ).fold( + ifLeft = { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error, + ) + }, + ifRight = { txFee -> + val fee = txFee.normal + val fiatFormatted = getFiatString( + value = fee.amount.value, + rate = status.value.fiatRate, + appCurrency = appCurrencyProvider(), + approximate = true, + ) + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Content( + feeSymbol = currency.symbol, + fiatFormatted = fiatFormatted, + ), + ) + }, + ) + } + } + + private fun disableWithConsolidationConfig(): DynamicAddressesBottomSheetConfig.DisableWithConsolidation { + return _bottomSheetConfig.value as? DynamicAddressesBottomSheetConfig.DisableWithConsolidation + ?: DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + onDisableClick = ::onDisableClick, + onRefreshFee = ::loadDisableFee, + onReadMoreClick = ::onReadMoreClick, + ) + } + + private fun onReadMoreClick() { + // TODO: Replace with actual URL + } + + private fun onDisableClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + coroutineScope.launch(dispatchers.main) { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + isSending = true, + ) + + val txData = createConsolidationTransactionUseCase(userWalletId, network).fold( + ifLeft = { error -> + TangemLogger.e("Failed to create consolidation tx: ${error.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + return@launch + }, + ifRight = { it }, + ) + + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = network, + ).fold( + ifLeft = { error -> + if (error is SendTransactionError.UserCancelledError) { + dismissBottomSheet() + } else { + TangemLogger.e("Failed to send consolidation tx: $error") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + } + }, + ifRight = { + try { + dynamicAddressesRepository.disable(userWalletId, network) + } catch (e: Exception) { + TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}") + } + dismissBottomSheet() + onDynamicAddressesStateChanged() + uiMessageSender.send( + SnackbarMessage( + message = resourceReference(R.string.dynamic_addresses_disabled_popup_title), + ), + ) + }, + ) + } + } + + // endregion + + // region Common + + private suspend fun isXpubAlreadyDerived(network: Network): Boolean { + return getDerivedXpubUseCase(userWalletId, network) != null + } + + private fun isUserCancellation(error: Throwable): Boolean { + return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled + } + + // endregion + + @AssistedFactory + interface Factory { + @Suppress("LongParameterList") + fun create( + userWallet: UserWallet, + cryptoCurrencyStatusProvider: Provider, + appCurrencyProvider: Provider, + coroutineScope: CoroutineScope, + @Assisted("showBottomSheet") showBottomSheet: () -> Unit, + @Assisted("dismissBottomSheet") dismissBottomSheet: () -> Unit, + @Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit, + ): DynamicAddressesDelegate + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt index 55ce756ae3..f5455c0b58 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -2,6 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState @@ -114,6 +115,12 @@ internal class ExpressTransactionsModel @Inject constructor( router.openUrl(url) } + override fun onReadAboutCrossChainBridgesClick() { + modelScope.launch { + router.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.AboutCrossChainBridges)) + } + } + override fun onConfirmDisposeExpressStatus() { uiMessageSender.send( DialogMessage( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index af6e80b86e..dcee418428 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -48,6 +48,10 @@ interface TokenDetailsClickIntents { fun onGenerateExtendedKey() + fun onDynamicAddressesClick() + + fun onDynamicAddressesFundsFoundLearnMoreClick() + fun onCopyAddress(): TextReference? fun onAssociateClick() @@ -96,6 +100,8 @@ interface ExpressTransactionsClickIntents { fun onOpenUrlClick(url: String) + fun onReadAboutCrossChainBridgesClick() + fun onConfirmDisposeExpressStatus() fun onDisposeExpressStatus() @@ -125,6 +131,10 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { override fun onGenerateExtendedKey() { /* no op */ } + override fun onDynamicAddressesClick() { /* no op */ } + + override fun onDynamicAddressesFundsFoundLearnMoreClick() { /* no op */ } + override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt new file mode 100644 index 0000000000..7a986acecf --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt @@ -0,0 +1,89 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import com.tangem.core.decompose.di.GlobalUiMessageSender +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.tokendetails.impl.R +import javax.inject.Inject + +@ModelScoped +internal class TokenDetailsDialogFactory @Inject constructor( + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, +) { + + fun showConfirmHideToken(currency: CryptoCurrency, onConfirm: () -> Unit) { + uiMessageSender.send( + DialogMessage( + title = resourceReference( + id = R.string.token_details_hide_alert_title, + formatArgs = wrappedList(currency.name), + ), + message = resourceReference(R.string.token_details_hide_alert_message), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.token_details_hide_alert_hide), + isWarning = true, + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } + + fun showLinkedTokens(currency: CryptoCurrency) { + uiMessageSender.send( + DialogMessage( + title = resourceReference( + id = R.string.token_details_unable_hide_alert_title, + formatArgs = wrappedList(currency.symbol), + ), + message = resourceReference( + id = R.string.token_details_unable_hide_alert_message, + formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name), + ), + ), + ) + } + + fun showDismissIncompleteTransactionConfirm(onConfirm: () -> Unit) { + uiMessageSender.send( + DialogMessage( + message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_yes), + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } + + fun showError(text: TextReference) { + uiMessageSender.send(DialogMessage(message = text)) + } + + fun showConfirmHideExpressStatus(onConfirm: () -> Unit) { + uiMessageSender.send( + DialogMessage( + title = resourceReference(R.string.express_status_hide_dialog_title), + message = resourceReference(R.string.express_status_hide_dialog_text), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_hide), + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 8214740b7b..0c00b2b411 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -2,13 +2,18 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable import arrow.core.getOrElse -import arrow.core.merge import arrow.core.right import com.tangem.utils.logging.TangemLogger import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.address.AddressType +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.receive.AddressModel @@ -16,31 +21,30 @@ import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent import com.tangem.core.decompose.di.GlobalUiMessageSender +import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.common.ui.tokens.getUnavailabilityReasonText +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager -import com.tangem.core.ui.message.DialogMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher @@ -53,6 +57,7 @@ import com.tangem.domain.models.TokenReceiveNotification import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -81,6 +86,7 @@ import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase +import com.tangem.domain.wallets.usecase.GetWalletIconUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase @@ -93,13 +99,19 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.Token import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.TokenDetailsExpressStatusFactory +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceLoadingTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateStakingNotificationTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateNotificationsTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter @@ -109,7 +121,6 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.isZero import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -118,6 +129,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration") +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Stable @ModelScoped internal class TokenDetailsModel @Inject constructor( @@ -154,7 +166,6 @@ internal class TokenDetailsModel @Inject constructor( private val appRouter: AppRouter, private val router: InnerTokenDetailsRouter, private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener, - private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val receiveAddressesFactory: ReceiveAddressesFactory, private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase, private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase, @@ -163,6 +174,17 @@ internal class TokenDetailsModel @Inject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase, private val signCloreMessageUseCase: SignCloreMessageUseCase, + private val isXpubSupportedUseCase: IsXpubSupportedUseCase, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory, + private val dialogFactory: TokenDetailsDialogFactory, + private val userWalletsListRepository: UserWalletsListRepository, + private val singleAccountListSupplier: SingleAccountListSupplier, + private val getWalletIconUseCase: GetWalletIconUseCase, + private val walletIconUMConverter: WalletIconUMConverter, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val designFeatureToggles: DesignFeatureToggles, + private val redesignStateController: TokenDetailsStateController, ) : Model(), TokenDetailsClickIntents, ExpressTransactionsClickIntents, @@ -183,6 +205,8 @@ internal class TokenDetailsModel @Inject constructor( private val stakingJobHolder = JobHolder() private val yieldSupplyBalanceJobHolder = JobHolder() private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + private val redesignBalanceJobHolder = JobHolder() + private val redesignEarnJobHolder = JobHolder() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null private var account: Account.CryptoPortfolio? = null @@ -204,11 +228,10 @@ internal class TokenDetailsModel @Inject constructor( userWalletId = userWalletId, ) - private val internalUiState = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency)) - val uiState: StateFlow = internalUiState + val uiState: StateFlow + field = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency)) - private val internalRedesignUiState = MutableStateFlow(createInitialRedesignState()) - val redesignUiState: StateFlow = internalRedesignUiState + val redesignUiState: StateFlow get() = redesignStateController.uiState // region Clore migration // TODO: Remove after Clore migration ends ([REDACTED_TASK_KEY]) @@ -226,6 +249,22 @@ internal class TokenDetailsModel @Inject constructor( } // endregion + // region Dynamic Addresses + val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) { + dynamicAddressesDelegateFactory.create( + userWallet = userWallet, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, + coroutineScope = modelScope, + showBottomSheet = { + bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses) + }, + dismissBottomSheet = bottomSheetNavigation::dismiss, + onDynamicAddressesStateChanged = ::onDynamicAddressesStateChanged, + ) + } + // endregion Dynamic Addresses + private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) { tokenDetailsExpressStatusFactory.create( clickIntents = this, @@ -249,6 +288,7 @@ internal class TokenDetailsModel @Inject constructor( } init { + initRedesign() updateTopBarMenu() initButtons() updateContent() @@ -299,7 +339,7 @@ internal class TokenDetailsModel @Inject constructor( private fun handleBalanceHiding() { getBalanceHidingSettingsUseCase() .onEach { settings -> - internalUiState.value = stateFactory.getStateWithUpdatedHidden( + uiState.value = stateFactory.getStateWithUpdatedHidden( isBalanceHidden = settings.isBalanceHidden, ) } @@ -315,7 +355,7 @@ internal class TokenDetailsModel @Inject constructor( .distinctUntilChanged() .onEach { state -> sendButtonsEvents(state.states) - internalUiState.value = stateFactory.getManageButtonsState(actions = state.states) + uiState.value = stateFactory.getManageButtonsState(actions = state.states) } .flowOn(dispatchers.main) .launchIn(modelScope) @@ -347,8 +387,15 @@ internal class TokenDetailsModel @Inject constructor( .distinctUntilChanged() .onEach { warnings -> val updatedState = stateFactory.getStateWithNotifications(warnings) - notificationsAnalyticsSender.send(internalUiState.value, updatedState.notifications) - internalUiState.value = updatedState + notificationsAnalyticsSender.send(uiState.value, updatedState.notifications) + uiState.value = updatedState + + redesignStateController.update( + UpdateNotificationsTransformer( + warnings = warnings, + clickIntents = this@TokenDetailsModel, + ), + ) } .launchIn(modelScope) .saveIn(warningsJobHolder) @@ -361,7 +408,7 @@ internal class TokenDetailsModel @Inject constructor( .map { it.status.right() } .distinctUntilChanged() .onEach { maybeCurrencyStatus -> - internalUiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus) + uiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus) maybeCurrencyStatus.onRight { status -> sendOneTimeBalanceLoadedAnalyticsEvent(status) cryptoCurrencyStatus = status @@ -383,7 +430,7 @@ internal class TokenDetailsModel @Inject constructor( .distinctUntilChanged() .onEach { waitForFirstExpressStatusEmmit.value = true } .onEach { expressTxs -> - internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( expressTxs = expressTxs, updateBalance = ::updateNetworkToSwapBalance, ) @@ -393,11 +440,11 @@ internal class TokenDetailsModel @Inject constructor( delay = EXPRESS_STATUS_UPDATE_DELAY, task = { runSuspendCatching { - expressStatusFactory.getUpdatedExpressStatuses(internalUiState.value.expressTxs) + expressStatusFactory.getUpdatedExpressStatuses(uiState.value.expressTxs) } }, onSuccess = { updatedTxs -> - internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( updatedTxs, ::updateNetworkToSwapBalance, ) @@ -418,14 +465,14 @@ internal class TokenDetailsModel @Inject constructor( } yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value) .onEach { formatted -> - internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted) + uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted) } .flowOn(dispatchers.main) .launchIn(modelScope) .saveIn(yieldSupplyBalanceJobHolder) } else { yieldSupplyBalanceJobHolder.cancel() - internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance( + uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance( YieldSupplyRewardBalance.empty(), ) } @@ -462,7 +509,7 @@ internal class TokenDetailsModel @Inject constructor( null } - internalUiState.update { state -> + uiState.update { state -> stateFactory.getStakingInfoState( state = state, stakingEntryInfo = stakingEntryInfo, @@ -484,39 +531,41 @@ internal class TokenDetailsModel @Inject constructor( ).getOrElse { false } val isSupported = isXPUBSupported() + val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable() - internalUiState.value = stateFactory.getStateWithUpdatedMenu( + uiState.value = stateFactory.getStateWithUpdatedMenu( userWallet = userWallet, hasDerivations = hasDerivations, isSupported = isSupported, + isDynamicAddressesAvailable = isDynamicAddressesAvailable, ) } } + private fun isDynamicAddressesAvailable(): Boolean { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false + if (cryptoCurrency !is CryptoCurrency.Coin) return false + + val networkId = cryptoCurrency.network.rawId + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(networkId)) return false + + return isDefaultBaseDerivation(cryptoCurrency.network.derivationPath, networkId) + } + + private fun isDefaultBaseDerivation(derivationPath: Network.DerivationPath, networkId: String): Boolean { + val pathValue = derivationPath.value ?: return false + val nodes = runCatching { DerivationPath(pathValue).nodes }.getOrNull() ?: return false + if (nodes.size < BASE_DERIVATION_NODE_COUNT) return false + + val purposeNode = nodes.first() + val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false + if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false + + return DynamicAddressesDerivationChecker.isBaseDerivation(pathValue) + } + private suspend fun isXPUBSupported(): Boolean { - return getExtendedPublicKeyForCurrencyUseCase.isSupported( - userWalletId = userWalletId, - network = cryptoCurrency.network, - ) - .mapLeft { throwable -> - analyticsExceptionHandler.sendException( - event = ExceptionAnalyticsEvent( - exception = throwable, - params = mapOf( - "blockchainId" to cryptoCurrency.network.id.rawId.value, - "networkId" to cryptoCurrency.network.backendId, - ), - ), - ) - - TangemLogger.e( - "Unable to get wallet manager for user wallet $userWalletId and network ${cryptoCurrency.network}", - throwable, - ) - - false - } - .merge() + return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network) } private fun createSelectedAppCurrencyFlow(): StateFlow { @@ -656,6 +705,19 @@ internal class TokenDetailsModel @Inject constructor( openStaking() } + override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick() + + override fun onDynamicAddressesFundsFoundLearnMoreClick() { + // TODO: open "Learn more" URL once the destination is decided + } + + private fun onDynamicAddressesStateChanged() { + updateTopBarMenu() + modelScope.launch(dispatchers.main) { + cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency) + } + } + override fun onGenerateExtendedKey() { modelScope.launch(dispatchers.main) { val extendedKey = getExtendedPublicKeyForCurrencyUseCase( @@ -729,7 +791,7 @@ internal class TokenDetailsModel @Inject constructor( } else { appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrency, + cryptoCurrency = cryptoCurrency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.Token.value, ), @@ -748,9 +810,9 @@ internal class TokenDetailsModel @Inject constructor( ) if (canHide) { - showConfirmHideTokenDialog(cryptoCurrency) + dialogFactory.showConfirmHideToken(currency = cryptoCurrency, onConfirm = ::onHideConfirmed) } else { - showLinkedTokensDialog(cryptoCurrency) + dialogFactory.showLinkedTokens(currency = cryptoCurrency) } } } @@ -844,7 +906,12 @@ internal class TokenDetailsModel @Inject constructor( } override fun onRefreshSwipe(isRefreshing: Boolean) { - internalUiState.value = stateFactory.getRefreshingState() + uiState.value = stateFactory.getRefreshingState() + redesignStateController.update( + SetBalanceLoadingTransformer( + currencyIconState = redesignStateController.value.balanceBlockUM.currencyIconState, + ), + ) modelScope.launch(dispatchers.main) { listOf( @@ -856,29 +923,29 @@ internal class TokenDetailsModel @Inject constructor( subscribeOnExpressTransactionsUpdates() }, ).awaitAll() - internalUiState.value = stateFactory.getRefreshedState() + uiState.value = stateFactory.getRefreshedState() }.saveIn(refreshStateJobHolder) } override fun onDismissBottomSheet() { - when (val bsContent = internalUiState.value.bottomSheetConfig?.content) { + when (val bsContent = uiState.value.bottomSheetConfig?.content) { is ExpressStatusBottomSheetConfig -> { modelScope.launch(dispatchers.main) { expressStatusFactory.removeTransactionOnBottomSheetClosed(bsContent.value) } } } - internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + uiState.value = stateFactory.getStateWithClosedBottomSheet() } override fun onCloseRentInfoNotification() { - internalUiState.value = stateFactory.getStateWithRemovedRentNotification() + uiState.value = stateFactory.getStateWithRemovedRentNotification() } override fun onExpressTransactionClick(txId: String) { - val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId } + val expressTxState = uiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId } ?: return - internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState) + uiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState) } override fun onGoToProviderClick(url: String) { @@ -893,6 +960,12 @@ internal class TokenDetailsModel @Inject constructor( router.openUrl(url) } + override fun onReadAboutCrossChainBridgesClick() { + modelScope.launch { + router.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.AboutCrossChainBridges)) + } + } + override fun onSwapPromoDismiss(promoId: PromoId) { modelScope.launch(dispatchers.main) { shouldShowPromoTokenUseCase.neverToShow(promoId) @@ -967,12 +1040,12 @@ internal class TokenDetailsModel @Inject constructor( } } if (message != null) { - showErrorDialog(stringReference(message)) + dialogFactory.showError(text = stringReference(message)) TangemLogger.e(message) } }, ifRight = { - internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() + uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() }, ) } @@ -1011,10 +1084,10 @@ internal class TokenDetailsModel @Inject constructor( } if (message != null) { - showErrorDialog(message) + dialogFactory.showError(text = message) } }, - ifRight = { internalUiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, + ifRight = { uiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, ) } } @@ -1026,7 +1099,9 @@ internal class TokenDetailsModel @Inject constructor( blockchain = cryptoCurrency.network.name, ), ) - showDismissIncompleteTransactionConfirmDialog() + dialogFactory.showDismissIncompleteTransactionConfirm( + onConfirm = ::onConfirmDismissIncompleteTransactionClick, + ) } override fun onConfirmDismissIncompleteTransactionClick() { @@ -1036,11 +1111,11 @@ internal class TokenDetailsModel @Inject constructor( currency = cryptoCurrency, ).fold( ifLeft = { e -> - showErrorDialog(stringReference(e.message.orEmpty())) + dialogFactory.showError(text = stringReference(e.message.orEmpty())) TangemLogger.e("Error: $e") }, ifRight = { - internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() + uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() }, ) } @@ -1061,7 +1136,8 @@ internal class TokenDetailsModel @Inject constructor( ifLeft = { e -> when (e) { is AssociateAssetError.NotEnoughBalance -> { - showErrorDialog( + dialogFactory.showError( + text = resourceReference( id = R.string.warning_hedera_token_association_not_enough_hbar_message, formatArgs = wrappedList(e.feeCurrency.symbol), @@ -1069,26 +1145,26 @@ internal class TokenDetailsModel @Inject constructor( ) } is AssociateAssetError.DataError -> { - showErrorDialog(stringReference(e.message.orEmpty())) + dialogFactory.showError(text = stringReference(e.message.orEmpty())) TangemLogger.e("Error: $e") } } }, - ifRight = { internalUiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() }, + ifRight = { uiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() }, ) } } override fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) { - internalUiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config) + uiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config) } override fun onConfirmDisposeExpressStatus() { - showConfirmHideExpressStatusDialog() + dialogFactory.showConfirmHideExpressStatus(onConfirm = ::onDisposeExpressStatus) } override fun onDisposeExpressStatus() { - val bottomSheetState = internalUiState.value.bottomSheetConfig?.content + val bottomSheetState = uiState.value.bottomSheetConfig?.content if (bottomSheetState is ExpressStatusBottomSheetConfig) { modelScope.launch { expressStatusFactory.removeTransactionOnBottomSheetClosed( @@ -1097,7 +1173,7 @@ internal class TokenDetailsModel @Inject constructor( ) } } - internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + uiState.value = stateFactory.getStateWithClosedBottomSheet() } override fun onYieldInfoClick() { @@ -1118,7 +1194,7 @@ internal class TokenDetailsModel @Inject constructor( private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean { if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false - showErrorDialog(unavailabilityReason.getUnavailabilityReasonText()) + dialogFactory.showError(text = unavailabilityReason.getUnavailabilityReasonText()) return true } @@ -1147,78 +1223,6 @@ internal class TokenDetailsModel @Inject constructor( uiMessageSender.send(SnackbarMessage(resourceReference(R.string.staking_error_no_validators_title))) } - private fun showConfirmHideTokenDialog(currency: CryptoCurrency) { - uiMessageSender.send( - DialogMessage( - title = resourceReference( - id = R.string.token_details_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference(R.string.token_details_hide_alert_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.token_details_hide_alert_hide), - isWarning = true, - onClick = ::onHideConfirmed, - ) - }, - secondActionBuilder = { cancelAction() }, - ), - ) - } - - private fun showLinkedTokensDialog(currency: CryptoCurrency) { - uiMessageSender.send( - DialogMessage( - title = resourceReference( - id = R.string.token_details_unable_hide_alert_title, - formatArgs = wrappedList(currency.symbol), - ), - message = resourceReference( - id = R.string.token_details_unable_hide_alert_message, - formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name), - ), - ), - ) - } - - private fun showDismissIncompleteTransactionConfirmDialog() { - uiMessageSender.send( - DialogMessage( - message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.common_yes), - onClick = ::onConfirmDismissIncompleteTransactionClick, - ) - }, - secondActionBuilder = { cancelAction() }, - ), - ) - } - - private fun showConfirmHideExpressStatusDialog() { - uiMessageSender.send( - DialogMessage( - title = resourceReference(R.string.express_status_hide_dialog_title), - message = resourceReference(R.string.express_status_hide_dialog_text), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.common_hide), - onClick = { - onDisposeExpressStatus() - }, - ) - }, - secondActionBuilder = { cancelAction() }, - ), - ) - } - - private fun showErrorDialog(text: TextReference) { - uiMessageSender.send(DialogMessage(message = text)) - } - private fun checkForActionUpdates() { combine( tokenDetailsDeepLinkActionListener.tokenDetailsActionFlow, @@ -1313,7 +1317,7 @@ internal class TokenDetailsModel @Inject constructor( TokenAction.Send -> sendCurrency() TokenAction.Swap -> appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrency, + cryptoCurrency = cryptoCurrency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.Token.value, ), @@ -1356,30 +1360,156 @@ internal class TokenDetailsModel @Inject constructor( // endregion Clore migration - private fun createInitialRedesignState(): TokenDetailsUM { - return TokenDetailsUM( - topAppBarUM = TokenDetailsTopAppBarUM( - title = stringReference(cryptoCurrency.name), - subtitle = stringReference(cryptoCurrency.symbol), - menuItems = persistentListOf(), + private fun observeRedesignBalance() { + getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { it.status } + .distinctUntilChanged() + .combine(selectedAppCurrencyFlow) { status, appCurrency -> status to appCurrency } + .onEach { (status, appCurrency) -> + redesignStateController.update( + SetBalanceTransformer( + status = status, + appCurrency = appCurrency, + onToggleBalanceType = ::toggleRedesignBalanceType, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + .saveIn(redesignBalanceJobHolder) + } + + private fun toggleRedesignBalanceType() { + redesignStateController.update(ToggleBalanceTypeTransformer()) + } + + private fun updateRedesignTopBarMenu() { + modelScope.launch(dispatchers.main) { + val hasDerivations = networkHasDerivationUseCase( + userWallet = userWallet, + network = cryptoCurrency.network, + ).getOrElse { false } + + val isSupported = isXPUBSupported() + + redesignStateController.update( + UpdateTopBarMenuTransformer( + userWallet = userWallet, + hasDerivations = hasDerivations, + isXPubSupported = isSupported, + onGenerateExtendedKey = ::onGenerateExtendedKey, + onHideClick = ::onHideClick, + ), + ) + } + } + + private fun initRedesign() { + if (!designFeatureToggles.isRedesignEnabled) return + initRedesignState() + observeRedesignBalance() + updateRedesignTopBarMenu() + observeRedesignTopBarTitle() + observeRedesignStakingNotification() + } + + private fun observeRedesignStakingNotification() { + val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { it.status } + .distinctUntilChanged() + + val availabilityFlow = getStakingAvailabilityUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + .map { it.getOrElse { StakingAvailability.Unavailable } } + .distinctUntilChanged() + + val entryInfoFlow = availabilityFlow.mapLatest { availability -> + (availability as? StakingAvailability.Available)?.let { available -> + getStakingEntryInfoUseCase( + cryptoCurrencyId = cryptoCurrency.id, + symbol = cryptoCurrency.symbol, + stakingOption = available.option, + ).getOrNull() + } + } + + combine( + flow = statusFlow, + flow2 = availabilityFlow, + flow3 = entryInfoFlow, + flow4 = selectedAppCurrencyFlow, + ) { status, availability, entryInfo, appCurrency -> + redesignStateController.update( + UpdateStakingNotificationTransformer( + cryptoCurrencyStatus = status, + stakingAvailability = availability, + stakingEntryInfo = entryInfo, + appCurrency = appCurrency, + clickIntents = this, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + .saveIn(redesignEarnJobHolder) + } + + private fun initRedesignState() { + redesignStateController.update( + InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = ::onBackClick, ), - balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( - actionButtons = persistentListOf(), - tokenBalanceTypeUM = TokenBalanceTypeUM.Single, - currencyIconState = CurrencyIconState.Loading, - ), - marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol), - stakingBlocksState = null, - pullToRefreshConfig = PullToRefreshConfig( - isRefreshing = false, - onRefresh = {}, - ), - isBalanceHidden = false, - isMarketPriceAvailable = false, ) } + private fun observeRedesignTopBarTitle() { + combine( + flow = userWalletsListRepository.userWallets.filterNotNull(), + flow2 = isAccountsModeEnabledUseCase.invoke(), + flow3 = singleAccountListSupplier(userWalletId), + flow4 = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { status -> status.account } + .distinctUntilChanged(), + ) { wallets, accountsModeEnabled, accountList, currentAccount -> + val currentWallet = wallets.firstOrNull { it.walletId == userWalletId } ?: userWallet + TopBarTitleInputs( + hasMultipleWallets = wallets.size > 1, + hasMultipleAccounts = accountsModeEnabled && accountList.accounts.size > 1, + walletName = currentWallet.name, + deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(currentWallet)), + account = currentAccount, + ) + } + .distinctUntilChanged() + .onEach { inputs -> + redesignStateController.update( + SetTopBarTitleTransformer( + cryptoCurrency = cryptoCurrency, + hasMultipleWallets = inputs.hasMultipleWallets, + hasMultipleAccounts = inputs.hasMultipleAccounts, + walletName = inputs.walletName, + deviceIconUM = inputs.deviceIconUM, + account = inputs.account, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private data class TopBarTitleInputs( + val hasMultipleWallets: Boolean, + val hasMultipleAccounts: Boolean, + val walletName: String, + val deviceIconUM: DeviceIconUM, + val account: Account.CryptoPortfolio?, + ) + private companion object { const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L + const val BASE_DERIVATION_NODE_COUNT = 5 } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt index 8ba904843b..071fa53e51 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt @@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route { @Serializable data object CloreMigration : TokenDetailsBottomSheetConfig() + + @Serializable + data object DynamicAddresses : TokenDetailsBottomSheetConfig() } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt index aab3bd7c14..a4cfef3e52 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tokendetails.impl.R import kotlinx.collections.immutable.ImmutableList @Immutable @@ -23,10 +25,25 @@ internal sealed class TokenDetailsBalanceBlockUM { override val actionButtons: ImmutableList, override val tokenBalanceTypeUM: TokenBalanceTypeUM, override val currencyIconState: CurrencyIconState, - val displayCryptoBalance: TextReference, - val displayFiatBalance: TextReference, + val displayCryptoBalanceAll: TextReference, + val displayFiatBalanceAll: TextReference, + val displayCryptoBalanceAvailable: TextReference?, + val displayFiatBalanceAvailable: TextReference?, val isBalanceFlickering: Boolean, - ) : TokenDetailsBalanceBlockUM() + ) : TokenDetailsBalanceBlockUM() { + + val displayCryptoBalance: TextReference + get() = when (tokenBalanceTypeUM.type) { + TokenBalanceTypeUM.Type.ALL -> displayCryptoBalanceAll + TokenBalanceTypeUM.Type.AVAILABLE -> displayCryptoBalanceAvailable ?: displayCryptoBalanceAll + } + + val displayFiatBalance: TextReference + get() = when (tokenBalanceTypeUM.type) { + TokenBalanceTypeUM.Type.ALL -> displayFiatBalanceAll + TokenBalanceTypeUM.Type.AVAILABLE -> displayFiatBalanceAvailable ?: displayFiatBalanceAll + } + } data class Error( override val actionButtons: ImmutableList, @@ -34,11 +51,11 @@ internal sealed class TokenDetailsBalanceBlockUM { override val currencyIconState: CurrencyIconState, ) : TokenDetailsBalanceBlockUM() - fun copyActionButtons(buttons: ImmutableList): TokenDetailsBalanceBlockUM { + fun copyCurrencyIconState(iconState: CurrencyIconState): TokenDetailsBalanceBlockUM { return when (this) { - is Content -> this.copy(actionButtons = buttons) - is Error -> this.copy(actionButtons = buttons) - is Loading -> this.copy(actionButtons = buttons) + is Content -> this.copy(currencyIconState = iconState) + is Error -> this.copy(currencyIconState = iconState) + is Loading -> this.copy(currencyIconState = iconState) } } } @@ -54,11 +71,11 @@ internal sealed class TokenBalanceTypeUM { data class Multiple( override val type: Type, val availableTypes: ImmutableList, - val onSelect: (Type) -> Unit, + val onSelect: () -> Unit, ) : TokenBalanceTypeUM() - enum class Type { - ALL, - AVAILABLE, + enum class Type(val text: TextReference) { + ALL(resourceReference(R.string.token_details_balance_total)), + AVAILABLE(resourceReference(R.string.token_details_balance_available)), } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt new file mode 100644 index 0000000000..6175a1fdd6 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -0,0 +1,75 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tokendetails.impl.R +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class TokenDetailsStateController @Inject constructor() { + + val uiState: StateFlow + field = MutableStateFlow(value = getInitialState()) + + val value: TokenDetailsUM get() = uiState.value + + fun update(function: (TokenDetailsUM) -> TokenDetailsUM) { + uiState.update(function = function) + } + + fun update(transformer: Transformer) { + uiState.update(function = transformer::transform) + } + + private fun getInitialState(): TokenDetailsUM { + return TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = ""), + subtitle = TextReference.EMPTY, + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf( + TangemButtonUM( + text = resourceReference(R.string.tangempay_card_details_add_funds), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = resourceReference(R.string.common_transfer), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + ), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + notifications = persistentListOf(), + earnBlockState = null, + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = ""), + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = {}, + ), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt index 14b55185e8..f67bd6b402 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt @@ -1,24 +1,53 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList -@Stable +@Immutable internal data class TokenDetailsUM( val topAppBarUM: TokenDetailsTopAppBarUM, val balanceBlockUM: TokenDetailsBalanceBlockUM, + val notifications: ImmutableList, + val earnBlockState: EarnBlockUM?, val marketPriceBlockState: MarketPriceBlockState, - val stakingBlocksState: StakingBlockUM?, val pullToRefreshConfig: PullToRefreshConfig, val isBalanceHidden: Boolean, val isMarketPriceAvailable: Boolean, ) +@Immutable internal data class TokenDetailsTopAppBarUM( - val title: TextReference, + val titleState: TitleState, val subtitle: TextReference, - val menuItems: ImmutableList, -) \ No newline at end of file + val onBackClick: () -> Unit, + val menuItems: ImmutableList, +) { + @Immutable + sealed interface TitleState { + val tokenName: String + + data class Simple( + override val tokenName: String, + ) : TitleState + + data class WithWallet( + override val tokenName: String, + val walletName: String, + val deviceIconUM: DeviceIconUM, + ) : TitleState + + data class WithAccount( + override val tokenName: String, + val accountName: TextReference, + val accountIconUM: AccountIconUM.CryptoPortfolio, + ) : TitleState + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt index c36b62c6e9..87911aada9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.notifications.CurrencyNotificationConfig import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resourceReference diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt index feb6279306..bb05ee4cc5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto @@ -276,6 +276,17 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { ), ) + data class DynamicAddressesFundsFound( + private val onLearnMoreClick: () -> Unit, + ) : Warning( + title = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_title), + subtitle = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_description), + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(id = R.string.common_learn_more), + onClick = onLearnMoreClick, + ), + ) + data class YieldSupplyNotTransferedToAave(val tokenName: String, val amount: String) : Warning( title = resourceReference( id = R.string.yield_module_amount_not_transfered_to_aave_title, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt index d0737b6df2..c65cac824f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.express import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification @@ -15,6 +16,7 @@ internal data class ExchangeUM( val statuses: ImmutableList, val notification: ExchangeStatusNotification?, val showProviderLink: Boolean, + val fromUserWalletId: UserWalletId, val fromCryptoCurrency: CryptoCurrency, val toCryptoCurrency: CryptoCurrency, val hasLongTime: Boolean, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt index df9bd31b13..71b5322124 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt @@ -1,7 +1,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import com.tangem.core.ui.extensions.getTintForTokenIcon -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.tokendetails.presentation.tokendetails.state.IconState diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index a57f83eafb..669c4a05df 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.format.bigdecimal.shorted import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings import com.tangem.domain.tokens.model.warnings.HederaWarnings import com.tangem.domain.tokens.model.warnings.KaspaWarnings import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -159,6 +160,9 @@ internal class TokenDetailsNotificationConverter( onMigrationClick = clickIntents::onCloreMigrationClick, ) is CryptoCurrencyWarning.UsedOutdatedDataWarning -> UsedOutdatedData + is DynamicAddressesWarnings.FundsFound -> DynamicAddressesFundsFound( + onLearnMoreClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick, + ) } } @@ -168,6 +172,6 @@ internal class TokenDetailsNotificationConverter( // workaround for networks that users have misunderstanding private fun CryptoCurrency.shouldMergeFeeNetworkName(): Boolean { - return Blockchain.fromNetworkId(this.network.backendId) == Blockchain.Arbitrum + return Blockchain.fromNetworkId(this.network.rawId) == Blockchain.Arbitrum } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt index 52acac7843..e2e0af401f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt @@ -1,17 +1,20 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import com.tangem.common.ui.expressStatus.state.* +import com.tangem.common.ui.expressStatus.toActiveStatusText +import com.tangem.common.ui.expressStatus.toIconState import com.tangem.common.ui.notifications.ExpressNotificationsUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.appcurrency.model.AppCurrency @@ -55,6 +58,8 @@ internal class TokenDetailsOnrampTransactionStateConverter( value.timestamp.toTimeFormat(), ), ), + timestampAgoFormatted = mapFormattedDate(value.timestamp), + activeStatus = value.status.toActiveStatusText(cryptoCurrency.name), toAmount = stringReference( value.toAmount.format { crypto(cryptoCurrency) }, ), @@ -82,7 +87,7 @@ internal class TokenDetailsOnrampTransactionStateConverter( url = value.fromCurrency.image, fallbackResId = R.drawable.ic_currency_24, ), - iconState = getIconState(value.status), + iconState = value.status.toIconState(), onGoToProviderClick = { url -> analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider()) clickIntents.onGoToProviderClick(url) @@ -124,18 +129,6 @@ internal class TokenDetailsOnrampTransactionStateConverter( null } - private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM { - return when (status) { - OnrampStatus.Status.RefundInProgress, - OnrampStatus.Status.Verifying, - -> ExpressTransactionStateIconUM.Warning - OnrampStatus.Status.Refunded, - OnrampStatus.Status.Failed, - -> ExpressTransactionStateIconUM.Error - else -> ExpressTransactionStateIconUM.None - } - } - private fun convertStatuses(status: OnrampStatus.Status, externalTxUrl: String?): ExpressStatusUM { val statuses = with(status) { persistentListOf( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index 649b2b064a..e0633eb981 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index 3739c109ba..bd9da5b438 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -167,12 +167,18 @@ internal class TokenDetailsStateFactory( userWallet: UserWallet, hasDerivations: Boolean, isSupported: Boolean, + isDynamicAddressesAvailable: Boolean = false, ): TokenDetailsState { return with(currentStateProvider()) { copy( topAppBarConfig = topAppBarConfig.copy( tokenDetailsAppBarMenuConfig = topAppBarConfig.tokenDetailsAppBarMenuConfig - ?.updateMenu(userWallet, hasDerivations, isSupported), + ?.updateMenu( + userWallet = userWallet, + hasDerivations = hasDerivations, + isSupported = isSupported, + isDynamicAddressesAvailable = isDynamicAddressesAvailable, + ), ), ) } @@ -206,6 +212,7 @@ internal class TokenDetailsStateFactory( userWallet: UserWallet, hasDerivations: Boolean, isSupported: Boolean, + isDynamicAddressesAvailable: Boolean, ): TokenDetailsAppBarMenuConfig? { if (userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() @@ -215,6 +222,13 @@ internal class TokenDetailsStateFactory( return copy( items = buildList { + if (isDynamicAddressesAvailable) { + TangemDropdownMenuItem( + title = resourceReference(R.string.dynamic_addresses), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = tokenDetailsClickIntents::onDynamicAddressesClick, + ).let(::add) + } if (isSupported && hasDerivations) { TangemDropdownMenuItem( title = resourceReference(R.string.token_details_generate_xpub), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index bc8cbf7110..454a03e171 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -5,7 +5,7 @@ import com.tangem.common.ui.expressStatus.state.ExpressStatusUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -13,12 +13,14 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.quote.mapData +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed @@ -38,7 +40,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal -import java.util.Locale // Fixme [REDACTED_JIRA] @Suppress("LargeClass") @@ -98,6 +99,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( val showProviderLink = getShowProviderLink(notification, transaction.status) result.add( ExchangeUM( + fromUserWalletId = UserWalletId(swapTransaction.fromUserWalletId), provider = transaction.provider, statuses = getStatuses(statusModel?.status), notification = notification, @@ -142,6 +144,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( info = tx.info.copy( txExternalId = statusModel.txExternalId, txExternalUrl = statusModel.txExternalUrl, + activeStatus = getActiveStatusText(statusModel.status), ), ) } @@ -163,6 +166,8 @@ internal class TokenDetailsSwapTransactionsStateConverter( timestampFormatted = stringReference( "${timestamp.toDateFormatWithTodayYesterday()}, ${timestamp.toTimeFormat()}", ), + timestampAgoFormatted = mapFormattedDate(timestamp), + activeStatus = getActiveStatusText(transaction.status?.status), toAmount = getCryptoAmount(transaction.toCryptoAmount, toCryptoCurrency), toFiatAmount = getFiatAmount(toFiatAmount), toCurrencyIcon = iconStateConverter.convert(toCryptoCurrency), @@ -230,7 +235,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( } else { ExchangeStatusNotification.TokenRefunded( cryptoCurrency = refundToken, - onReadMoreClick = { clickIntents.onOpenUrlClick(url = getAboutCrossChainBridgesLink()) }, + onReadMoreClick = clickIntents::onReadAboutCrossChainBridgesClick, onGoToTokenClick = { clickIntents.onGoToRefundedTokenClick(refundToken) }, ) } @@ -250,6 +255,26 @@ internal class TokenDetailsSwapTransactionsStateConverter( } } + private fun getActiveStatusText(status: ExchangeStatus?): TextReference = when (status) { + ExchangeStatus.New, + ExchangeStatus.Waiting, + -> resourceReference(R.string.express_exchange_status_receiving_active) + ExchangeStatus.WaitingTxHash -> resourceReference(R.string.express_exchange_status_waiting_tx_hash) + ExchangeStatus.Confirming -> resourceReference(R.string.express_exchange_status_confirming_active) + ExchangeStatus.Verifying -> resourceReference(R.string.express_exchange_status_verifying) + ExchangeStatus.Exchanging -> resourceReference(R.string.express_exchange_status_exchanging_active) + ExchangeStatus.Sending -> resourceReference(R.string.express_exchange_status_sending_active) + ExchangeStatus.Finished -> resourceReference(R.string.express_exchange_status_sent) + ExchangeStatus.Refunded -> resourceReference(R.string.express_exchange_status_refunded) + ExchangeStatus.Paused -> resourceReference(R.string.express_exchange_status_paused) + ExchangeStatus.Cancelled -> resourceReference(R.string.express_exchange_status_canceled) + ExchangeStatus.Failed, + ExchangeStatus.TxFailed, + ExchangeStatus.Unknown, + -> resourceReference(R.string.express_exchange_status_failed) + null -> TextReference.EMPTY + } + private fun getIconState(status: ExchangeStatus?): ExpressTransactionStateIconUM { return when (status) { ExchangeStatus.Verifying -> ExpressTransactionStateIconUM.Warning @@ -422,12 +447,4 @@ internal class TokenDetailsSwapTransactionsStateConverter( isDone = isSendingDone, ) } - - private fun getAboutCrossChainBridgesLink(): String { - return if (Locale.getDefault().country == "RU") { - "https://tangem.com/ru/blog/post/an-overview-of-cross-chain-bridges/" - } else { - "https://tangem.com/en/blog/post/an-overview-of-cross-chain-bridges/" - } - } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 3bc30b0d7c..e2465a65bd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -12,8 +12,10 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* @@ -21,6 +23,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTr import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter import com.tangem.utils.Provider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,7 +32,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map -import com.tangem.utils.logging.TangemLogger @Suppress("LongParameterList") internal class ExchangeStatusFactory @AssistedInject constructor( @@ -40,6 +42,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, + private val getUserWalletUseCase: GetUserWalletUseCase, @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @@ -59,6 +62,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( operator fun invoke(): Flow> { return swapTransactionRepository.getTransactions( userWallet = userWallet, + cryptoCurrencyId = cryptoCurrency.id, ).conflate() .map { savedTransactions -> @@ -93,7 +97,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( return if (swapTx.activeStatus?.isTerminal == true) { swapTx } else { - val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider) + val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId) if (statusModel != null) { swapTransactionsStateConverter.updateTxStatus( @@ -106,15 +110,24 @@ internal class ExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { - return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId) + private suspend fun getExchangeStatus( + txId: String, + provider: SwapProvider, + fromUserWalletId: UserWalletId, + ): ExchangeStatusModel? { + val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrNull() + return swapRepository.getExchangeStatus( + userWallet = fromUserWallet, + userWalletId = fromUserWalletId, + txId = txId, + ) .fold( ifLeft = { null }, ifRight = { statusModel -> sendStatusUpdateAnalytics(statusModel, provider) val accountId = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWallet.walletId, + userWalletId = fromUserWalletId, currency = cryptoCurrency, ) .map { it.account.accountId } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt index a8af24a292..c7b54127e2 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express +import arrow.core.getOrElse import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.swap.ExpressAnalyticsStatus @@ -11,8 +12,10 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* @@ -21,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter import com.tangem.utils.Provider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,7 +33,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map -import com.tangem.utils.logging.TangemLogger import kotlin.coroutines.cancellation.CancellationException @Suppress("LongParameterList") @@ -41,6 +44,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, + private val getUserWalletUseCase: GetUserWalletUseCase, @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @@ -94,7 +98,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( return if (swapTx.activeStatus?.isTerminal == true) { swapTx } else { - val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider) + val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId) if (statusModel != null) { swapTransactionsStateConverter.updateTxStatus( @@ -107,43 +111,54 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { - return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId) - .fold( - ifLeft = { null }, - ifRight = { statusModel -> - sendStatusUpdateAnalytics(statusModel, provider) + private suspend fun getExchangeStatus( + txId: String, + provider: SwapProvider, + fromUserWalletId: UserWalletId, + ): ExchangeStatusModel? { + val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrElse { error -> + TangemLogger.e("Couldn't find userWallet: $error") + return null + } + return swapRepository.getExchangeStatus( + userWallet = fromUserWallet, + userWalletId = fromUserWalletId, + txId = txId, + ).fold( + ifLeft = { null }, + ifRight = { statusModel -> + sendStatusUpdateAnalytics(statusModel, provider) - val accountId = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - ) - .map { it.account.accountId } - .getOrNull() + val accountId = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = fromUserWalletId, + currency = cryptoCurrency, + ) + .map { it.account.accountId } + .getOrNull() - val refundTokenCurrency = if (accountId != null) { - addRefundCurrencyIfNeeded( - accountId = accountId, - status = statusModel, - type = provider.type, - ) - } else { - TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}") - null - } - - swapTransactionRepository.storeTransactionState( - txId = txId, + val refundTokenCurrency = if (accountId != null) { + addRefundCurrencyIfNeeded( + accountId = accountId, status = statusModel, - accountWithCurrency = if (refundTokenCurrency != null) { - Pair(accountId, refundTokenCurrency) - } else { - null - }, + type = provider.type, ) - statusModel.copy(refundCurrency = refundTokenCurrency) - }, - ) + } else { + TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}") + null + } + + swapTransactionRepository.storeTransactionState( + txId = txId, + status = statusModel, + accountWithCurrency = if (refundTokenCurrency != null) { + Pair(accountId, refundTokenCurrency) + } else { + null + }, + ) + statusModel.copy(refundCurrency = refundTokenCurrency) + }, + ) } private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel, provider: SwapProvider) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt new file mode 100644 index 0000000000..8a219600dc --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class InitializeWithCryptoCurrencyTransformer( + private val cryptoCurrency: CryptoCurrency, + private val onBackClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val iconState = CryptoCurrencyToIconStateConverter().convert(cryptoCurrency) + return prevState.copy( + topAppBarUM = prevState.topAppBarUM.copy( + titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name), + subtitle = stringReference(cryptoCurrency.symbol), + onBackClick = onBackClick, + ), + balanceBlockUM = prevState.balanceBlockUM.copyCurrencyIconState(iconState), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformer.kt new file mode 100644 index 0000000000..5b4852a7e3 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class SetBalanceLoadingTransformer( + private val currencyIconState: CurrencyIconState, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val prevBalance = prevState.balanceBlockUM + return prevState.copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = prevBalance.actionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = currencyIconState, + ), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt new file mode 100644 index 0000000000..28da2953eb --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt @@ -0,0 +1,131 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getTotalWithRewardsStakingBalance +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.defaultAmount +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.StringsSigns.DASH_SIGN +import com.tangem.utils.isNullOrZero +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +/** + * Maps [CryptoCurrencyStatus] into [TokenDetailsBalanceBlockUM] and sets it on the state. + * + * Produces [TokenDetailsBalanceBlockUM.Content] for loaded states, + * [TokenDetailsBalanceBlockUM.Loading] for loading, + * [TokenDetailsBalanceBlockUM.Error] for unreachable/no-amount/missed-derivation. + */ +internal class SetBalanceTransformer( + private val status: CryptoCurrencyStatus, + private val appCurrency: AppCurrency, + private val onToggleBalanceType: () -> Unit, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val prev = prevState.balanceBlockUM + val balanceBlockUM = when (status.value) { + is CryptoCurrencyStatus.Loading -> TokenDetailsBalanceBlockUM.Loading( + actionButtons = prev.actionButtons, + tokenBalanceTypeUM = prev.tokenBalanceTypeUM, + currencyIconState = prev.currencyIconState, + ) + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + is CryptoCurrencyStatus.Custom, + -> buildLoadedContent(prev) + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> TokenDetailsBalanceBlockUM.Error( + actionButtons = prev.actionButtons, + tokenBalanceTypeUM = prev.tokenBalanceTypeUM, + currencyIconState = prev.currencyIconState, + ) + } + return prevState.copy(balanceBlockUM = balanceBlockUM) + } + + private fun buildLoadedContent(prev: TokenDetailsBalanceBlockUM): TokenDetailsBalanceBlockUM.Content { + val stakingCryptoAmount = + (status.value.stakingBalance as? StakingBalance.Data)?.getTotalWithRewardsStakingBalance( + status.currency.network.rawId, + ) + val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } + val hasStaking = !stakingCryptoAmount.isNullOrZero() + + val prevType = prev.tokenBalanceTypeUM + val tokenBalanceTypeUM = if (hasStaking) { + TokenBalanceTypeUM.Multiple( + type = (prevType as? TokenBalanceTypeUM.Multiple)?.type ?: TokenBalanceTypeUM.Type.ALL, + availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE), + onSelect = onToggleBalanceType, + ) + } else { + TokenBalanceTypeUM.Single + } + + return TokenDetailsBalanceBlockUM.Content( + actionButtons = prev.actionButtons, + currencyIconState = prev.currencyIconState, + tokenBalanceTypeUM = tokenBalanceTypeUM, + displayFiatBalanceAll = formatFiatStyled( + fiatAmount = computeTotal(status.value.fiatAmount, stakingFiatAmount), + ), + displayCryptoBalanceAll = formatCrypto( + amount = computeTotal(status.value.amount, stakingCryptoAmount), + ), + displayFiatBalanceAvailable = if (hasStaking) { + formatFiatStyled(fiatAmount = status.value.fiatAmount) + } else { + null + }, + displayCryptoBalanceAvailable = if (hasStaking) { + formatCrypto(amount = status.value.amount) + } else { + null + }, + isBalanceFlickering = status.value.sources.total == StatusSource.CACHE, + ) + } + + private fun computeTotal(base: BigDecimal?, staking: BigDecimal?): BigDecimal? { + if (base == null) return null + return if (staking != null) base + staking else base + } + + private fun formatFiatStyled(fiatAmount: BigDecimal?): TextReference { + if (fiatAmount == null) return stringReference(DASH_SIGN) + return fiatAmount.formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).defaultAmount( + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + } + } + + private fun formatCrypto(amount: BigDecimal?): TextReference { + if (amount == null) return stringReference(DASH_SIGN) + return stringReference( + amount.format { crypto(status.currency).defaultAmount() }, + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformer.kt new file mode 100644 index 0000000000..443e904a62 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformer.kt @@ -0,0 +1,78 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer +import com.tangem.core.res.R as CoreResR + +internal class SetTopBarTitleTransformer( + private val cryptoCurrency: CryptoCurrency, + private val hasMultipleWallets: Boolean, + private val hasMultipleAccounts: Boolean, + private val walletName: String, + private val deviceIconUM: DeviceIconUM, + private val account: Account.CryptoPortfolio?, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy( + topAppBarUM = prevState.topAppBarUM.copy( + titleState = createTitleState(), + subtitle = createSubtitle(), + ), + ) + + private fun createTitleState(): TitleState { + val tokenName = cryptoCurrency.name + + return when { + hasMultipleAccounts && account != null -> { + val accountNameUM = account.accountName.toUM() + TitleState.WithAccount( + tokenName = tokenName, + accountName = accountNameUM.value, + accountIconUM = AccountIconUM.CryptoPortfolio( + value = account.icon.value, + color = account.icon.color, + ), + ) + } + hasMultipleWallets -> TitleState.WithWallet( + tokenName = tokenName, + walletName = walletName, + deviceIconUM = deviceIconUM, + ) + else -> TitleState.Simple(tokenName = tokenName) + } + } + + private fun createSubtitle(): TextReference { + val networkName = cryptoCurrency.network.name + return when (cryptoCurrency) { + is CryptoCurrency.Token -> { + val standardName = cryptoCurrency.network.standardType + .takeIf { it !is Network.StandardType.Unspecified } + ?.name + if (standardName != null) { + resourceReference( + CoreResR.string.token_details_toolbar_subtitle_standard, + wrappedList(standardName, networkName), + ) + } else { + resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName)) + } + } + is CryptoCurrency.Coin -> { + resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName)) + } + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformer.kt new file mode 100644 index 0000000000..69302a474a --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformer.kt @@ -0,0 +1,31 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer + +/** + * Toggles the balance type between [TokenBalanceTypeUM.Type.ALL] and [TokenBalanceTypeUM.Type.AVAILABLE]. + * + * No-op if the current balance state is not [TokenDetailsBalanceBlockUM.Content] or its + * [TokenDetailsBalanceBlockUM.Content.tokenBalanceTypeUM] is not [TokenBalanceTypeUM.Multiple]. + */ +internal class ToggleBalanceTypeTransformer : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val content = prevState.balanceBlockUM as? TokenDetailsBalanceBlockUM.Content ?: return prevState + val multiple = content.tokenBalanceTypeUM as? TokenBalanceTypeUM.Multiple ?: return prevState + + val nextType = when (multiple.type) { + TokenBalanceTypeUM.Type.ALL -> TokenBalanceTypeUM.Type.AVAILABLE + TokenBalanceTypeUM.Type.AVAILABLE -> TokenBalanceTypeUM.Type.ALL + } + + return prevState.copy( + balanceBlockUM = content.copy( + tokenBalanceTypeUM = multiple.copy(type = nextType), + ), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt new file mode 100644 index 0000000000..40232ef9c8 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt @@ -0,0 +1,240 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessageButtonUM +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.message.TangemMessageUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings +import com.tangem.domain.tokens.model.warnings.HederaWarnings +import com.tangem.domain.tokens.model.warnings.KaspaWarnings +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import com.tangem.core.res.R as CoreResR + +internal class UpdateNotificationsTransformer( + private val warnings: Set, + private val clickIntents: TokenDetailsClickIntents, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val notifications = warnings.mapNotNull(::mapWarning).toImmutableList() + return prevState.copy(notifications = notifications) + } + + @Suppress("LongMethod") + private fun mapWarning(warning: CryptoCurrencyWarning): TangemMessageUM? { + return when (warning) { + is CryptoCurrencyWarning.SomeNetworksUnreachable -> TangemMessageUM( + id = "networks_unreachable", + title = resourceReference(CoreResR.string.warning_network_unreachable_title), + subtitle = resourceReference(CoreResR.string.warning_network_unreachable_message), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is CryptoCurrencyWarning.BalanceNotEnoughForFee -> createFeeWarning( + FeeWarningParams( + id = "balance_not_enough_for_fee", + currency = warning.tokenCurrency, + networkName = warning.coinCurrency.network.name, + feeCurrencyName = warning.coinCurrency.name, + feeCurrencySymbol = warning.coinCurrency.symbol, + buyCurrency = warning.coinCurrency, + ), + ) + is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> createFeeWarning( + FeeWarningParams( + id = "custom_token_not_enough_for_fee", + currency = warning.currency, + networkName = warning.feeCurrency?.network?.name ?: warning.networkName, + feeCurrencyName = warning.feeCurrencyName, + feeCurrencySymbol = warning.feeCurrencySymbol, + buyCurrency = warning.feeCurrency, + ), + ) + is CryptoCurrencyWarning.BeaconChainShutdown -> TangemMessageUM( + id = "beacon_chain_shutdown", + title = resourceReference(CoreResR.string.warning_beacon_chain_retirement_title), + subtitle = resourceReference(CoreResR.string.warning_beacon_chain_retirement_content), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is HederaWarnings.AssociateWarning -> TangemMessageUM( + id = "hedera_associate", + title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title), + subtitle = resourceReference(CoreResR.string.warning_hedera_missing_token_association_message_brief), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onAssociateClick, + ), + ), + ) + is HederaWarnings.AssociateWarningWithFee -> TangemMessageUM( + id = "hedera_associate_fee", + title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title), + subtitle = resourceReference( + CoreResR.string.warning_hedera_missing_token_association_message, + wrappedList( + warning.fee.format { crypto(symbol = "", decimals = warning.feeCurrencyDecimals) }, + warning.feeCurrencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onAssociateClick, + ), + ), + ) + is CryptoCurrencyWarning.RequiredTrustline -> TangemMessageUM( + id = "required_trustline", + title = resourceReference(CoreResR.string.warning_token_trustline_title), + subtitle = resourceReference( + CoreResR.string.warning_token_trustline_subtitle, + wrappedList( + warning.requiredAmount.format { crypto(symbol = "", warning.currencyDecimals) }.trim(), + warning.currencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_token_trustline_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onOpenTrustlineClick, + ), + ), + ) + is KaspaWarnings.IncompleteTransaction -> TangemMessageUM( + id = "kaspa_incomplete", + title = resourceReference(CoreResR.string.warning_kaspa_unfinished_token_transaction_title), + subtitle = resourceReference( + CoreResR.string.warning_kaspa_unfinished_token_transaction_message, + wrappedList( + warning.amount.format { crypto(symbol = "", decimals = warning.currencyDecimals) }, + warning.currencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.alert_button_try_again), + type = TangemButtonType.Primary, + onClick = clickIntents::onRetryIncompleteTransactionClick, + ), + ), + onCloseClick = clickIntents::onDismissIncompleteTransactionClick, + ) + is CryptoCurrencyWarning.MigrationMaticToPol -> TangemMessageUM( + id = "migration_matic_pol", + title = resourceReference(CoreResR.string.warning_matic_migration_title), + subtitle = resourceReference(CoreResR.string.warning_matic_migration_message), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is CryptoCurrencyWarning.MigrationClore -> TangemMessageUM( + id = "migration_clore", + title = resourceReference(CoreResR.string.warning_clore_migration_title), + subtitle = resourceReference(CoreResR.string.warning_clore_migration_description), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_clore_migration_button), + type = TangemButtonType.Primary, + onClick = clickIntents::onCloreMigrationClick, + ), + ), + ) + is DynamicAddressesWarnings.FundsFound -> TangemMessageUM( + id = "dynamic_addresses_funds_found", + title = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_title), + subtitle = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_description), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.common_learn_more), + type = TangemButtonType.Primary, + onClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick, + ), + ), + ) + // Non-warning types — skip for redesign + is CryptoCurrencyWarning.ExistentialDeposit, + is CryptoCurrencyWarning.Rent, + is CryptoCurrencyWarning.SomeNetworksNoAccount, + is CryptoCurrencyWarning.TopUpWithoutReserve, + is CryptoCurrencyWarning.SwapPromo, + is CryptoCurrencyWarning.FeeResourceInfo, + is CryptoCurrencyWarning.UsedOutdatedDataWarning, + -> null + } + } + + private fun createFeeWarning(params: FeeWarningParams): TangemMessageUM { + val buttons = if (params.buyCurrency != null) { + persistentListOf( + TangemMessageButtonUM( + text = resourceReference( + CoreResR.string.common_buy_currency, + wrappedList(params.feeCurrencySymbol), + ), + type = TangemButtonType.Primary, + onClick = { clickIntents.onBuyCoinClick(params.buyCurrency) }, + ), + ) + } else { + persistentListOf() + } + + return TangemMessageUM( + id = params.id, + title = resourceReference( + CoreResR.string.warning_send_blocked_funds_for_fee_title, + wrappedList(params.feeCurrencyName), + ), + subtitle = resourceReference( + CoreResR.string.warning_send_blocked_funds_for_fee_message, + wrappedList( + params.currency.name, + params.networkName, + params.currency.name, + params.feeCurrencyName, + params.feeCurrencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = buttons, + ) + } + + private data class FeeWarningParams( + val id: String, + val currency: CryptoCurrency, + val networkName: String, + val feeCurrencyName: String, + val feeCurrencySymbol: String, + val buyCurrency: CryptoCurrency?, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt new file mode 100644 index 0000000000..46fbf2bac8 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -0,0 +1,282 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getRewardStakingBalance +import com.tangem.common.getTotalStakingBalance +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.defaultAmount +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.RewardBlockType +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingOption +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.features.tokendetails.impl.R +import com.tangem.lib.crypto.BlockchainUtils.isStakingRewardUnavailable +import com.tangem.utils.isNullOrZero +import com.tangem.utils.transformer.Transformer +import java.math.BigDecimal +import com.tangem.core.res.R as CoreResR +import com.tangem.core.ui.R as CoreUiR + +internal class UpdateStakingNotificationTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val stakingAvailability: StakingAvailability, + private val stakingEntryInfo: StakingEntryInfo?, + private val appCurrency: AppCurrency, + private val clickIntents: TokenDetailsClickIntents, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + return prevState.copy(earnBlockState = buildEarnBlock(prevState.isBalanceHidden)) + } + + private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? { + return when (val availability = stakingAvailability) { + StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable() + StakingAvailability.Unavailable -> null + is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden) + } + } + + private fun buildTemporaryUnavailable(): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.Staking, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Disabled, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.staking_notification_network_error_text), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Disabled, + ), + trailingUM = null, + ) + } + + private fun getStakingInfoBlock( + availability: StakingAvailability.Available, + isBalanceHidden: Boolean, + ): EarnBlockUM? { + val status = cryptoCurrencyStatus + val stakingBalance = status.value.stakingBalance as? StakingBalance.Data + val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId) + + return when { + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> { + val hasPendingBalances = stakingBalance.hasPendingBalances() + if (!hasPendingBalances) { + buildStakeAvailable( + availability = availability, + isEnabled = isStakingButtonEnabled(status), + ) + } else { + buildActiveBlock( + stakingAmount = stakingBalance.getPendingAmount(), + rewardAmount = null, + isBalanceHidden = isBalanceHidden, + ) + } + } + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo == null -> null + else -> buildActiveBlock( + stakingAmount = stakingCryptoAmount, + rewardAmount = stakingBalance.getRewardAmount(), + isBalanceHidden = isBalanceHidden, + ) + } + } + + private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean { + return status.value is CryptoCurrencyStatus.Loaded || + status.value is CryptoCurrencyStatus.NoQuote || + status.value is CryptoCurrencyStatus.Custom + } + + private fun buildStakeAvailable( + availability: StakingAvailability.Available, + isEnabled: Boolean, + ): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.Staking, + backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(id = R.string.token_details_staking_block_title), + style = EarnBlockUM.TitleUM.Style.Small, + tone = EarnBlockUM.TitleUM.Tone.Accent, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stakeAvailableSubtitle(availability.option.displayApy), + style = EarnBlockUM.SubtitleUM.Style.Large, + tone = EarnBlockUM.SubtitleUM.Tone.Primary, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + text = resourceReference(R.string.common_stake), + isEnabled = isEnabled, + ), + onClick = clickIntents::onStakeBannerClick, + ) + } + + private fun stakeAvailableSubtitle(apy: BigDecimal?): TextReference { + return if (apy != null) { + resourceReference( + CoreResR.string.token_details_earn_staking_subtitle, + wrappedList(apy.format { percent() }), + ) + } else { + resourceReference(CoreResR.string.staking_notification_earn_rewards_text) + } + } + + private fun buildActiveBlock( + stakingAmount: BigDecimal?, + rewardAmount: BigDecimal?, + isBalanceHidden: Boolean, + ): EarnBlockUM.Content { + val status = cryptoCurrencyStatus + val fiatRate = status.value.fiatRate + val fiatAmount = stakingAmount?.let { fiatRate?.multiply(it) } + val rewardFiatAmount = rewardAmount?.let { fiatRate?.multiply(it) } + return EarnBlockUM.Content( + type = EarnBlockUM.Type.Staking, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = getRewardSubtitle(status, rewardFiatAmount), + trailingUM = EarnBlockUM.TrailingUM.Balance( + fiatValue = fiatAmount.formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).defaultAmount( + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + }, + cryptoValue = stringReference( + stakingAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + isBalanceHidden = isBalanceHidden, + ), + onClick = clickIntents::onStakeBannerClick, + ) + } + + private fun getRewardSubtitle( + status: CryptoCurrencyStatus, + stakingRewardAmount: BigDecimal?, + ): EarnBlockUM.SubtitleUM? { + val blockchainId = status.currency.network.rawId + val isCoin = status.currency.id.isCoin + val stakingBalance = status.value.stakingBalance + + val rewardBlockType = when { + stakingBalance is StakingBalance.Data.P2PEthPool -> { + if (stakingBalance.totalRewards.isNullOrZero()) { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } else { + RewardBlockType.EthereumEarnedRewards + } + } + isStakingRewardUnavailable(blockchainId, isCoin) -> { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } + stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards + else -> RewardBlockType.Rewards + } + + val text = when (rewardBlockType) { + RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim) + RewardBlockType.CardanoNoRewards -> resourceReference(R.string.staking_cardano_details_rewards_info_text) + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable, + RewardBlockType.RewardUnavailable.SolanaRewardUnavailable, + -> return null + RewardBlockType.EthereumEarnedRewards -> { + val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards + resourceReference( + R.string.staking_details_autocompound_rewards_earned, + wrappedList( + cryptoRewardAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + ) + } + RewardBlockType.RewardsRequirementsError, + RewardBlockType.Rewards, + -> resourceReference( + R.string.staking_details_rewards_to_claim, + wrappedList( + stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) }, + ), + ) + } + + val isAccent = rewardBlockType == RewardBlockType.Rewards || + rewardBlockType == RewardBlockType.RewardsRequirementsError || + rewardBlockType == RewardBlockType.EthereumEarnedRewards + + return EarnBlockUM.SubtitleUM.Text( + text = text, + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled, + ) + } +} + +private fun StakingBalance.Data?.hasPendingBalances(): Boolean = when (this) { + is StakingBalance.Data.StakeKit -> balance.items.isNotEmpty() + is StakingBalance.Data.P2PEthPool -> !unstakingAmount.isNullOrZero() + null -> false +} + +private fun StakingBalance.Data?.getPendingAmount(): BigDecimal = when (this) { + is StakingBalance.Data.StakeKit -> balance.items.sumOf { it.amount } + is StakingBalance.Data.P2PEthPool -> unstakingAmount + null -> BigDecimal.ZERO +} + +private fun StakingBalance.Data?.getRewardAmount(): BigDecimal = when (this) { + is StakingBalance.Data.StakeKit -> getRewardStakingBalance() + is StakingBalance.Data.P2PEthPool -> totalRewards + null -> BigDecimal.ZERO +} + +private val StakingOption.displayApy: BigDecimal? + get() = when (this) { + is StakingOption.StakeKit -> yield.preferredValidators + .mapNotNull { it.rewardInfo?.rate } + .maxOrNull() + is StakingOption.P2PEthPool -> apy + } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt new file mode 100644 index 0000000000..320e28bdce --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt @@ -0,0 +1,51 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.features.tokendetails.impl.R +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class UpdateTopBarMenuTransformer( + private val userWallet: UserWallet, + private val hasDerivations: Boolean, + private val isXPubSupported: Boolean, + private val onGenerateExtendedKey: () -> Unit, + private val onHideClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy( + topAppBarUM = prevState.topAppBarUM.copy(menuItems = createMenuItems()), + ) + + private fun createMenuItems() = if (userWallet is UserWallet.Cold && + userWallet.cardTypesResolver.isSingleWalletWithToken() + ) { + persistentListOf() + } else { + buildList { + if (isXPubSupported && hasDerivations) { + add( + TangemDropdownMenuItem( + title = resourceReference(R.string.token_details_generate_xpub), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = onGenerateExtendedKey, + ), + ) + } + add( + TangemDropdownMenuItem( + title = resourceReference(R.string.token_details_hide_token), + textColor = themedColor { TangemTheme.colors.text.warning }, + onClick = onHideClick, + ), + ) + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 01902e1cab..03668be768 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -1,30 +1,305 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui +import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import com.tangem.common.ui.earn.EarnBlock +import com.tangem.common.ui.notifications.notifications + import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM -import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged + +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.components.BottomFade +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar +import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.LocalRootBackgroundColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight +import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.entity.TxHistoryUM +import com.tangem.features.yield.supply.api.YieldSupplyComponent +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeTint +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +private val TopBarHeight: Dp = 64.dp +private val MarketBlockHorizontalPadding: Dp = 14.dp -@Suppress("UnusedParameter") @Composable internal fun TokenDetailsScreen( tokenDetailsUM: TokenDetailsUM, tokenMarketBlockComponent: TokenMarketBlockComponent?, + yieldSupplyComponent: YieldSupplyComponent, + txHistoryComponent: TxHistoryComponent, modifier: Modifier = Modifier, ) { + val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } + val partialCollapsedHeight = TopBarHeight + statusBarHeight + val expandedHeight = TokenDetailsBalanceBlockHeight + partialCollapsedHeight + + val behavior = rememberTangemExitUntilCollapsedScrollBehavior( + expandedHeight = expandedHeight, + partialCollapsedHeight = partialCollapsedHeight, + ) + + val rootBackground by LocalRootBackgroundColor.current + var marketBlockHeight by remember { mutableStateOf(0.dp) } + val notificationModifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + Box( modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, ) { - Text( - text = "Token Details Redesign", - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, + Box( + modifier = Modifier + .fillMaxSize() + .hazeSourceTangem(zIndex = -2f), + ) { + TangemCollapsingTopBar( + state = behavior.state, + collapsingPart = { + TokenDetailsBalanceBlock( + balanceBlockUM = tokenDetailsUM.balanceBlockUM, + behavior = behavior, + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(top = TopBarHeight), + ) + }, + body = { + TokenDetailsBody( + tokenDetailsUM = tokenDetailsUM, + yieldSupplyComponent = yieldSupplyComponent, + txHistoryComponent = txHistoryComponent, + rootBackground = rootBackground, + bottomContentPadding = marketBlockHeight, + modifier = Modifier + .fillMaxSize() + .nestedScroll(behavior.nestedScrollConnection), + itemModifier = notificationModifier, + ) + }, + ) + } + + TokenDetailsTopBarOverlay( + topAppBarUM = tokenDetailsUM.topAppBarUM, + collapsedFraction = behavior.state.collapsedFraction, + rootBackground = rootBackground, + ) + + if (tokenMarketBlockComponent != null) { + TokenDetailsMarketBlockOverlay( + component = tokenMarketBlockComponent, + rootBackground = rootBackground, + onHeightChange = { marketBlockHeight = it }, + ) + } + } +} + +@Composable +private fun TokenDetailsTopBarOverlay( + topAppBarUM: TokenDetailsTopAppBarUM, + collapsedFraction: Float, + rootBackground: Color, +) { + val hazeIntensity by animateFloatAsState( + targetValue = (collapsedFraction * 2f).coerceIn(0f, 1f), + label = "TopBarHazeIntensity", + ) + Box( + modifier = Modifier.hazeEffectTangem { + fallbackTint = HazeTint(rootBackground.copy(alpha = hazeIntensity / 2f)) + progressive = HazeProgressive.verticalGradient( + startIntensity = hazeIntensity, + endIntensity = 0f, + preferPerformance = true, + ) + }, + ) { + TokenDetailsTopBar(topAppBarUM = topAppBarUM) + } +} + +@Composable +private fun BoxScope.TokenDetailsMarketBlockOverlay( + component: TokenMarketBlockComponent, + rootBackground: Color, + onHeightChange: (Dp) -> Unit, +) { + val density = LocalDensity.current + + BottomFade( + gradientBrush = Brush.verticalGradient( + colors = listOf( + rootBackground.copy(alpha = 0f), + rootBackground, + ), + ), + modifier = Modifier.align(Alignment.BottomCenter), + ) + + component.Content( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + onHeightChange(with(density) { size.height.toDp() }) + } + .navigationBarsPadding() + .padding( + horizontal = MarketBlockHorizontalPadding, + vertical = TangemTheme.dimens2.x1_5, + ), + ) +} + +@Composable +private fun TokenDetailsBody( + tokenDetailsUM: TokenDetailsUM, + yieldSupplyComponent: YieldSupplyComponent, + txHistoryComponent: TxHistoryComponent, + rootBackground: Color, + bottomContentPadding: Dp, + modifier: Modifier = Modifier, + itemModifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val txHistoryState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle() + + LazyColumn( + modifier = modifier, + state = listState, + contentPadding = PaddingValues(bottom = bottomContentPadding), + ) { + notifications( + notifications = tokenDetailsUM.notifications, + contentColor = rootBackground, + modifier = itemModifier, + ) + tokenDetailsUM.earnBlockState?.let { earnBlock -> + item(key = "staking_block") { + EarnBlock( + state = earnBlock, + modifier = itemModifier, + ) + } + } + item(key = "yield_supply_block") { + yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2)) + } + with(txHistoryComponent) { + txHistoryContent(listState = listState, state = txHistoryState) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenDetailsScreen_Preview() { + TangemThemePreviewRedesign { + TokenDetailsScreen( + tokenMarketBlockComponent = null, + tokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.WithAccount( + tokenName = "Tether", + accountName = stringReference("Portfolio"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf( + TangemDropdownMenuItem( + title = stringReference("Hide Token"), + textColor = themedColor { TangemTheme.colors.text.warning }, + onClick = {}, + ), + ), + ), + notifications = persistentListOf(), + earnBlockState = null, + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = "USDT"), + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = {}, + ), + isBalanceHidden = false, + isMarketPriceAvailable = true, + ), + yieldSupplyComponent = object : YieldSupplyComponent { + @Composable + override fun Content(modifier: Modifier) = Unit + }, + txHistoryComponent = object : TxHistoryComponent { + override val txHistoryState: StateFlow = MutableStateFlow( + value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}), + ) + + override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit + + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit + }, ) } -} \ No newline at end of file +} +// endregion Preview \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index 21fdf29fd8..fed9925378 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -31,7 +31,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsTopAppBar import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.ExpressStatusBottomSheet -import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlock +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryUM @@ -133,7 +133,7 @@ internal fun TokenDetailsScreenLegacy( key = StakingBlockUM::class.java, contentType = StakingBlockUM::class.java, content = { - TokenStakingBlock( + TokenStakingBlockLegacy( state = state.stakingBlocksState, isBalanceHidden = state.isBalanceHidden, modifier = itemModifier, @@ -151,7 +151,9 @@ internal fun TokenDetailsScreenLegacy( modifier = itemModifier, ) - with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryComponentState) } + with(txHistoryComponent) { + txHistoryContentLegacy(listState = listState, state = txHistoryComponentState) + } } } @@ -179,6 +181,8 @@ private fun TokenDetailsScreenPreview( value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}), ) + override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit }, yieldSupplyComponent = object : YieldSupplyComponent { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt new file mode 100644 index 0000000000..aaf3a4838f --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt @@ -0,0 +1,478 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.account.AccountIcon +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.R as CoreUiR + +@Composable +internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: Modifier = Modifier) { + TangemTopBar( + modifier = modifier.statusBarsPadding(), + startContent = { + TangemTopBarActionContent( + actionUM = TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_back_24, + onClick = topAppBarUM.onBackClick, + ghostModeProgress = 1f, + ), + ) + }, + endContent = if (topAppBarUM.menuItems.isNotEmpty()) { + { + var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } + Box { + TangemTopBarActionContent( + actionUM = TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_more_default_24, + onClick = { isDropdownMenuShown = true }, + ghostModeProgress = 1f, + ), + ) + TangemDropdownMenu( + expanded = isDropdownMenuShown, + modifier = Modifier.background(TangemTheme.colors.background.primary), + onDismissRequest = { isDropdownMenuShown = false }, + content = { + topAppBarUM.menuItems.fastForEach { menuItem -> + TangemDropdownItem( + item = menuItem, + dismissParent = { isDropdownMenuShown = false }, + ) + } + }, + ) + } + } + } else { + null + }, + content = { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = TangemTheme.dimens2.x1), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5), + ) { + TokenDetailsTitle(titleState = topAppBarUM.titleState) + Text( + text = topAppBarUM.subtitle.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.captionMedium12, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) +} + +@Composable +private fun TokenDetailsTitle(titleState: TitleState) { + val appearance = TitleAppearance( + style = TangemTheme.typography2.bodySemibold16, + iconSize = TangemTheme.dimens2.x5, + spacing = TangemTheme.dimens2.x1, + ) + + when (titleState) { + is TitleState.Simple -> { + Text( + text = titleState.tokenName, + color = TangemTheme.colors2.text.neutral.primary, + style = appearance.style, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = MIN_TITLE_FONT_SIZE, + maxFontSize = MAX_TITLE_FONT_SIZE, + ), + ) + } + is TitleState.WithWallet -> { + AdaptiveTokenWithSecondaryRow( + tokenName = titleState.tokenName, + secondaryName = titleState.walletName, + template = stringResourceSafe( + id = CoreUiR.string.token_details_toolbar_title_token_in_wallet, + titleState.tokenName, + titleState.walletName, + ), + appearance = appearance, + icon = { + TangemDeviceIcon( + state = titleState.deviceIconUM, + modifier = Modifier.size(appearance.iconSize), + ) + }, + ) + } + is TitleState.WithAccount -> { + val accountNameStr = titleState.accountName.resolveAnnotatedReference().toString() + AdaptiveTokenWithSecondaryRow( + tokenName = titleState.tokenName, + secondaryName = accountNameStr, + template = stringResourceSafe( + id = CoreUiR.string.token_details_toolbar_title_token_in_account, + titleState.tokenName, + accountNameStr, + ), + appearance = appearance, + icon = { + AccountIcon( + name = titleState.accountName, + icon = titleState.accountIconUM, + size = AccountIconSize.ExtraSmall, + ) + }, + ) + } + } +} + +/** + * Adaptive title for [TitleState.WithAccount] / [TitleState.WithWallet]. + * + * Phrase template carries the [IMAGE_PLACEHOLDER] marker — translator decides where + * the icon sits (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]"). + * RTL is handled by BiDi inside the single [Text]. + * + * Width-driven cascade: + * 1–2. Full phrase as single [Text] with inline icon; [TextOverflow.Ellipsis] + * trims the secondary name tail when needed. + * 3. No meaningful tail left → fall back to `[tokenName] [icon]` in a [Row]; + * icon is a sibling so ellipsis trims only tokenName, never the icon. + * 4–5. tokenName itself doesn't fit → [TextAutoSize] shrinks to + * [MIN_TITLE_FONT_SIZE], then [TextOverflow.Ellipsis] tails. + * + * #1/#2 vs #3 is decided here via [rememberTextMeasurer]; #4/#5 are delegated + * to [TextAutoSize] + [TextOverflow.Ellipsis] in the fallback branch. + */ +@Composable +private fun AdaptiveTokenWithSecondaryRow( + tokenName: String, + secondaryName: String, + template: String, + appearance: TitleAppearance, + icon: @Composable () -> Unit, +) { + val (beforeIcon, afterIcon) = remember(template) { splitTemplate(template) } + val inlineContent = rememberIconInlineContent(appearance.iconSize, icon) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val isFullTextShown = rememberShouldShowFullText( + beforeIcon = beforeIcon, + afterIcon = afterIcon, + secondaryName = secondaryName, + appearance = appearance, + maxWidthPx = constraints.maxWidth, + ) + if (isFullTextShown) { + FullPhraseTitle( + beforeIcon = beforeIcon, + afterIcon = afterIcon, + style = appearance.style, + inlineContent = inlineContent, + ) + } else { + FallbackTokenWithIconTitle( + tokenName = tokenName, + appearance = appearance, + icon = icon, + ) + } + } +} + +private fun splitTemplate(template: String): Pair { + val parts = template.split(IMAGE_PLACEHOLDER, limit = 2) + return if (parts.size == 2) parts[0] to parts[1] else template to "" +} + +@Composable +private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit): Map { + val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() } + val currentIcon by rememberUpdatedState(icon) + return remember(iconSizeSp) { + mapOf( + ICON_INLINE_ID to InlineTextContent( + placeholder = Placeholder( + width = iconSizeSp, + height = iconSizeSp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + currentIcon() + } + }, + ), + ) + } +} + +@Composable +private fun rememberShouldShowFullText( + beforeIcon: String, + afterIcon: String, + secondaryName: String, + appearance: TitleAppearance, + maxWidthPx: Int, +): Boolean { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + return remember(beforeIcon, afterIcon, secondaryName, maxWidthPx, appearance, density) { + if (maxWidthPx <= 0) return@remember true + val fullTextWidthPx = measurer + .measure(text = beforeIcon + afterIcon, style = appearance.style, softWrap = false) + .size.width + val secondaryWidthPx = measurer + .measure(text = secondaryName, style = appearance.style, softWrap = false) + .size.width + val staticWidthPx = (fullTextWidthPx - secondaryWidthPx).coerceAtLeast(0) + val iconReservePx = with(density) { + (appearance.iconSize + appearance.spacing * 2).toPx() + }.toInt() + val minSecondaryPx = with(density) { MIN_SECONDARY_NAME_WIDTH.toPx() }.toInt() + staticWidthPx + iconReservePx + minSecondaryPx <= maxWidthPx + } +} + +@Composable +private fun FullPhraseTitle( + beforeIcon: String, + afterIcon: String, + style: TextStyle, + inlineContent: Map, +) { + val fullText = remember(beforeIcon, afterIcon) { + buildAnnotatedString { + append(beforeIcon) + appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER) + append(afterIcon) + } + } + Text( + text = fullText, + inlineContent = inlineContent, + color = TangemTheme.colors2.text.neutral.primary, + style = style, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun FallbackTokenWithIconTitle(tokenName: String, appearance: TitleAppearance, icon: @Composable () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(appearance.spacing, Alignment.CenterHorizontally), + ) { + Text( + text = tokenName, + color = TangemTheme.colors2.text.neutral.primary, + style = appearance.style, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = MIN_TITLE_FONT_SIZE, + maxFontSize = MAX_TITLE_FONT_SIZE, + ), + modifier = Modifier.weight(weight = 1f, fill = false), + ) + icon() + } +} + +@Immutable +private data class TitleAppearance( + val style: TextStyle, + val iconSize: Dp, + val spacing: Dp, +) + +private const val ICON_INLINE_ID = "account_icon" +private const val IMAGE_PLACEHOLDER = "%image%" + +private val MIN_TITLE_FONT_SIZE = 12.sp +private val MAX_TITLE_FONT_SIZE = 16.sp +private val MIN_SECONDARY_NAME_WIDTH = 48.dp + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenDetailsTopBar_Preview( + @PreviewParameter(TokenDetailsTopBarPreviewProvider::class) titleState: TitleState, +) { + TangemThemePreviewRedesign { + TokenDetailsTopBar( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = titleState, + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf( + TangemDropdownMenuItem( + title = stringReference("Hide Token"), + textColor = themedColor { TangemTheme.colors.text.warning }, + onClick = {}, + ), + ), + ), + ) + } +} + +private class TokenDetailsTopBarPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // === Base title states === + // Simple — 1 wallet, 1 account + TitleState.Simple(tokenName = "Tether"), + // WithWallet — N wallets, 1 account + TitleState.WithWallet( + tokenName = "Tether", + walletName = "Tangem wallet", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // WithAccount — 1 wallet, N accounts + TitleState.WithAccount( + tokenName = "Tether", + accountName = stringReference("Portfolio"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + // === AdaptiveTokenTitleRow cascade cases === + // Cascade #1 — full text fits as is (short token + short wallet) + TitleState.WithWallet( + tokenName = "BTC", + walletName = "Main", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // Cascade #2 — full text doesn't fit, ellipsized tail still meaningful + TitleState.WithWallet( + tokenName = "Tether", + walletName = "My Long Tangem Hardware Wallet", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // Cascade #3 — secondary part too small to be meaningful, drop to token-only + icon + TitleState.WithWallet( + tokenName = "USDCoinWrapped", + walletName = "Super Extra Long Wallet Name That Definitely Wont Fit", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // Cascade #4 — even tokenName alone doesn't fit at full font size: TextAutoSize shrinks it + TitleState.WithAccount( + tokenName = "VeryLongTokenNameThatOverflowsVeryLongTokenNameThatOverflows", + accountName = stringReference("Portfolio"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + // Cascade #5 — even at MIN_TITLE_FONT_SIZE doesn't fit: TextOverflow.Ellipsis tails it + TitleState.Simple( + tokenName = "ExtremelyLongTokenNameThatCannotPossiblyFitEvenAtMinFontSize", + ), + // Account variant — long account name triggers ellipsized tail (#2) + TitleState.WithAccount( + tokenName = "Tether", + accountName = stringReference("My Personal Long Account Name"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + // Account variant — drop secondary, icon-only fallback (#3) + TitleState.WithAccount( + tokenName = "USDCoinWrapped", + accountName = stringReference("Super Extra Long Account Name That Wont Fit Anywhere"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt new file mode 100644 index 0000000000..6b476ee779 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.feature.tokendetails.presentation.tokendetails.model.DynamicAddressesDelegate +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheet + +internal class DynamicAddressesBottomSheetComponent( + private val dynamicAddressesDelegate: DynamicAddressesDelegate, + private val onDismiss: () -> Unit, +) : ComposableBottomSheetComponent { + + override fun dismiss() { + onDismiss() + } + + @Composable + override fun BottomSheet() { + val content by dynamicAddressesDelegate.bottomSheetConfig.collectAsStateWithLifecycle() + + val config = remember(content) { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = content, + ) + } + DynamicAddressesBottomSheet(config = config) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt new file mode 100644 index 0000000000..5de00c3eb0 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -0,0 +1,258 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.button.action.ActionButtons +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior +import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior +import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalRootBackgroundColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.features.tokendetails.impl.R +import kotlinx.collections.immutable.persistentListOf + +private val CurrencyIconSize: Dp = 70.dp +private val NetworkBadgeSize: Dp = 24.dp +internal val TokenDetailsBalanceBlockHeight: Dp = 404.dp +private const val MIN_SCALE = 0.75f +private const val MAX_SCALE = 1f + +@Composable +internal fun TokenDetailsBalanceBlock( + balanceBlockUM: TokenDetailsBalanceBlockUM, + behavior: TangemCollapsingAppBarBehavior, + modifier: Modifier = Modifier, +) { + val rootBackground by LocalRootBackgroundColor.current + val collapsedFraction = behavior.state.collapsedFraction + val alpha = 1f - collapsedFraction + val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .alpha(alpha) + .scale(scale) + .snapToExitUntilCollapsed(behavior) + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x10), + ) { + CurrencyIcon( + state = balanceBlockUM.currencyIconState, + shouldDisplayNetwork = true, + iconSize = CurrencyIconSize, + networkBadgeSize = NetworkBadgeSize, + networkBadgeBackground = rootBackground, + ) + SpacerH(TangemTheme.dimens2.x3) + when (balanceBlockUM) { + is TokenDetailsBalanceBlockUM.Content -> ContentBody(state = balanceBlockUM) + is TokenDetailsBalanceBlockUM.Loading -> LoadingBody() + is TokenDetailsBalanceBlockUM.Error -> ErrorBody() + } + SpacerH(TangemTheme.dimens2.x10) + ActionButtons(buttons = balanceBlockUM.actionButtons) + } +} + +@Composable +private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content) { + AnimatedContent( + targetState = state.tokenBalanceTypeUM.type, + label = "Token balance type", + ) { currentType -> + val tokenBalanceTypeUM = state.tokenBalanceTypeUM + when (tokenBalanceTypeUM) { + is TokenBalanceTypeUM.Multiple -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + modifier = Modifier.clickable(onClick = tokenBalanceTypeUM.onSelect), + ) { + Text( + text = currentType.text.resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = TangemTheme.colors2.text.neutral.secondary, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_sort_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.secondary, + modifier = Modifier.size(TangemTheme.dimens2.x4), + ) + } + TokenBalanceTypeUM.Single -> Text( + text = currentType.text.resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = TangemTheme.colors2.text.neutral.secondary, + ) + } + } + SpacerH(TangemTheme.dimens2.x2) + Text( + text = state.displayFiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.titleRegular44, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x2_5) + Text( + text = state.displayCryptoBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.secondary, + ) +} + +@Composable +private fun LoadingBody() { + Text( + text = TokenBalanceTypeUM.Type.ALL.text.resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = TangemTheme.colors2.text.neutral.secondary, + ) + SpacerH(TangemTheme.dimens2.x2) + TextShimmer( + style = TangemTheme.typography2.titleRegular44, + text = "$1234567890", + radius = TangemTheme.dimens2.x6, + ) + SpacerH(TangemTheme.dimens2.x2) + TextShimmer( + style = TangemTheme.typography2.bodySemibold16, + text = "12345.67", + radius = TangemTheme.dimens2.x4, + ) +} + +@Composable +private fun ErrorBody() { + Text( + text = "—", + style = TangemTheme.typography2.titleRegular44, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x2_5) + Text( + text = "—", + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.secondary, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenDetailsBalanceBlock_Preview( + @PreviewParameter(PreviewProvider::class) params: TokenDetailsBalanceBlockUM, +) { + TangemThemePreviewRedesign { + TokenDetailsBalanceBlock( + balanceBlockUM = params, + behavior = rememberTangemExitUntilCollapsedScrollBehavior(), + modifier = Modifier.background(TangemTheme.colors2.surface.level2), + ) + } +} + +private class PreviewProvider : PreviewParameterProvider { + + private val previewActionButtons = persistentListOf( + TangemButtonUM( + text = stringReference("Add funds"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_arrow_down_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = stringReference("Transfer"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_arrow_up_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + ) + + override val values: Sequence + get() = sequenceOf( + TokenDetailsBalanceBlockUM.Content( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple( + type = TokenBalanceTypeUM.Type.ALL, + availableTypes = persistentListOf( + TokenBalanceTypeUM.Type.ALL, + TokenBalanceTypeUM.Type.AVAILABLE, + ), + onSelect = { }, + ), + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("0.0613884 BTC"), + displayFiatBalanceAll = stringReference("$12,380.94"), + displayCryptoBalanceAvailable = stringReference("0.05 BTC"), + displayFiatBalanceAvailable = stringReference("$10,000.00"), + isBalanceFlickering = false, + ), + TokenDetailsBalanceBlockUM.Content( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("123.456 USDT"), + displayFiatBalanceAll = stringReference("$123.45"), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ), + TokenDetailsBalanceBlockUM.Loading( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + TokenDetailsBalanceBlockUM.Error( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt new file mode 100644 index 0000000000..556fd79f30 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt @@ -0,0 +1,38 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.R as CoreR + +@Composable +internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + title = { + TangemModalBottomSheetTitle( + endIconRes = CoreR.drawable.ic_close_24, + onEndClick = config.onDismissRequest, + ) + }, + ) { content -> + when (content) { + is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content) + is DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation -> { + DynamicAddressesDisableWithoutConsolidationContent(content = content) + } + is DynamicAddressesBottomSheetConfig.DisableWithConsolidation -> { + DynamicAddressesDisableWithConsolidationContent(content = content) + } + is DynamicAddressesBottomSheetConfig.ConflictingCustomTokens -> { + DynamicAddressesConflictingCustomTokensContent(content = content) + } + is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> { + DynamicAddressesServiceUnavailableContent(content = content) + } + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt new file mode 100644 index 0000000000..7270a74d1e --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt @@ -0,0 +1,44 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +@Immutable +internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfigContent { + + data class Enable( + val isCardScanRequired: Boolean, + val isLoading: Boolean = false, + val onEnableClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class DisableWithoutConsolidation( + val onDisableClick: () -> Unit, + val onReadMoreClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class DisableWithConsolidation( + val feeState: DisableFeeState = DisableFeeState.Loading, + val isSending: Boolean = false, + val onDisableClick: () -> Unit, + val onRefreshFee: () -> Unit, + val onReadMoreClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + sealed interface DisableFeeState { + data object Loading : DisableFeeState + data class Content( + val feeSymbol: String, + val fiatFormatted: String, + ) : DisableFeeState + data object Error : DisableFeeState + } + + data class ConflictingCustomTokens( + val onDismissClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class ServiceUnavailable( + val onDismissClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt new file mode 100644 index 0000000000..1b40922d1d --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt @@ -0,0 +1,529 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.audits.AuditLabel +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.res.R +import com.tangem.core.ui.R as CoreR +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig.DisableFeeState + +@Composable +internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_top), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.accent, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses_enter_subtitle), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + FeatureItem( + iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_flash_24, + title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_title), + description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_description), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + + FeatureItem( + iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_check_24, + title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_title), + description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_description), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButtonIconEnd( + text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title), + iconResId = if (content.isCardScanRequired) CoreR.drawable.ic_tangem_24 else null, + onClick = content.onEnableClick, + modifier = Modifier.fillMaxWidth(), + showProgress = content.isLoading, + enabled = !content.isLoading, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +internal fun DynamicAddressesDisableWithoutConsolidationContent( + content: DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DisableHeader() + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButtonIconEnd( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title), + iconResId = null, + onClick = content.onDisableClick, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +internal fun DynamicAddressesDisableWithConsolidationContent( + content: DynamicAddressesBottomSheetConfig.DisableWithConsolidation, +) { + val isConfirmEnabled = content.feeState is DisableFeeState.Content && !content.isSending + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DisableHeader() + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + + DisableFeeBlock( + feeState = content.feeState, + onReadMoreClick = content.onReadMoreClick, + ) + + if (content.feeState is DisableFeeState.Error) { + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Notification( + config = NotificationConfig( + title = resourceReference(R.string.send_fee_unreachable_error_title), + subtitle = resourceReference(R.string.send_fee_unreachable_error_text), + iconResId = CoreR.drawable.ic_alert_24, + iconTint = NotificationConfig.IconTint.Warning, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_button_refresh), + onClick = content.onRefreshFee, + ), + ), + containerColor = TangemTheme.colors.background.action, + ) + } + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButtonIconEnd( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title), + iconResId = CoreR.drawable.ic_tangem_24, + onClick = content.onDisableClick, + modifier = Modifier.fillMaxWidth(), + showProgress = content.isSending, + enabled = isConfirmEnabled, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +private fun DisableHeader() { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.attention, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun DisableFeeBlock(feeState: DisableFeeState, onReadMoreClick: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResourceSafe(id = R.string.common_network_fee_title), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + when (feeState) { + is DisableFeeState.Loading -> TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier.size( + width = TangemTheme.dimens.size80, + height = TangemTheme.dimens.spacing16, + ), + ) + is DisableFeeState.Content -> Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AuditLabel( + state = AuditLabelUM( + text = stringReference(feeState.feeSymbol), + type = AuditLabelUM.Type.General, + ), + ) + Text( + text = feeState.fiatFormatted, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + is DisableFeeState.Error -> Text( + text = "\u2014", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + } + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + DisableFeeDescription(onReadMoreClick = onReadMoreClick) + } +} + +@Composable +private fun DisableFeeDescription(onReadMoreClick: () -> Unit) { + val readMoreText = stringResourceSafe(id = R.string.common_read_more) + val fullText = stringResourceSafe(id = R.string.dynamic_addresses_disable_fee_description) + + val annotatedString = buildAnnotatedString { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(fullText) + append(" ") + } + withLink( + link = LinkAnnotation.Clickable( + tag = "read_more", + linkInteractionListener = { onReadMoreClick() }, + ), + ) { + withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { + append(readMoreText) + } + } + } + + Text( + text = annotatedString, + style = TangemTheme.typography.caption2, + ) +} + +@Composable +internal fun DynamicAddressesConflictingCustomTokensContent( + content: DynamicAddressesBottomSheetConfig.ConflictingCustomTokens, +) { + ErrorContent( + titleRes = R.string.dynamic_addresses_error_has_custom_token_title, + descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description, + buttonTextRes = R.string.common_got_it, + onButtonClick = content.onDismissClick, + ) +} + +@Composable +internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddressesBottomSheetConfig.ServiceUnavailable) { + ErrorContent( + titleRes = R.string.dynamic_addresses_error_service_unavailable_title, + descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description, + buttonTextRes = R.string.common_got_it, + onButtonClick = content.onDismissClick, + ) +} + +@Composable +private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, onButtonClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.attention, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = titleRes), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = descriptionRes), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButton( + text = stringResourceSafe(id = buttonTextRes), + onClick = onButtonClick, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +private fun FeatureItem(iconRes: Int, title: String, description: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size24), + tint = TangemTheme.colors.icon.accent, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing4)) + Text( + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} + +// region Previews + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_Enable() { + TangemThemePreview { + DynamicAddressesEnableContent( + content = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + onEnableClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_EnableWithCardScan() { + TangemThemePreview { + DynamicAddressesEnableContent( + content = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = true, + onEnableClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableWithoutConsolidation() { + TangemThemePreview { + DynamicAddressesDisableWithoutConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation( + onDisableClick = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableFeeLoading() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Loading, + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableFeeLoaded() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Content( + feeSymbol = "BTC", + fiatFormatted = "~$0.12", + ), + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableFeeError() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Error, + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableSending() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Content( + feeSymbol = "BTC", + fiatFormatted = "~$0.12", + ), + isSending = true, + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ConflictingCustomTokens() { + TangemThemePreview { + DynamicAddressesConflictingCustomTokensContent( + content = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(onDismissClick = {}), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ServiceUnavailable() { + TangemThemePreview { + DynamicAddressesServiceUnavailableContent( + content = DynamicAddressesBottomSheetConfig.ServiceUnavailable(onDismissClick = {}), + ) + } +} + +// endregion \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt index 427406a32b..1a0a125cb4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency.ID import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.SwapProvider @@ -23,6 +24,7 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider + when (stakingBlock) { is StakingBlockUM.TemporaryUnavailable -> StakingTemporaryUnavailableBlock() is StakingBlockUM.Loading -> StakingLoading() is StakingBlockUM.Staked -> StakingBalanceBlock( - state = it, + state = stakingBlock, isBalanceHidden = isBalanceHidden, ) is StakingBlockUM.StakeAvailable -> StakingAvailableContent( - state = it, + state = stakingBlock, ) } } @@ -165,7 +165,7 @@ private fun Preview_TokenStakingBlock( state: StakingBlockUM, ) { TangemThemePreview { - TokenStakingBlock( + TokenStakingBlockLegacy( state = state, isBalanceHidden = false, ) diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..079ec97e5a --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt @@ -0,0 +1,485 @@ +package com.tangem.feature.tokendetails.deeplink + +import arrow.core.Either +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.error.SelectWalletError +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.wallet.WalletBalanceFetcher +import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.SelectWalletUseCase +import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger +import com.tangem.utils.logging.TangemLogger +import io.mockk.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultTokenDetailsDeepLinkHandlerTest { + + private val appRouter: AppRouter = mockk() + private val selectWalletUseCase: SelectWalletUseCase = mockk() + private val getSelectedWalletSync: GetSelectedWalletSyncUseCase = mockk() + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk() + private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger = mockk() + private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk() + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + private val walletBalanceFetcher: WalletBalanceFetcher = mockk() + private val singleAccountListSupplier: SingleAccountListSupplier = mockk() + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + mockkObject(TangemLogger) + every { analyticsEventHandler.send(any()) } just Runs + every { appRouter.push(any(), any()) } just Runs + val userWallet: UserWallet = mockk() + every { userWallet.walletId } returns mockk() + every { getSelectedWalletSync() } returns Either.Right( + value = userWallet + ) + } + + @Test + fun `GIVEN error instead of user wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf(WALLET_ID_KEY to "011") + every { + getUserWalletUseCase.invoke( + userWalletId = UserWalletId( + "011" + ) + ) + } returns Either.Left( + value = GetUserWalletError.UserWalletNotFound + ) + every { TangemLogger.e("Error on getting user wallet") } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e("Error on getting user wallet") } + } + + @Test + fun `GIVEN locked user wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf(WALLET_ID_KEY to "011") + every { + getUserWalletUseCase.invoke( + userWalletId = UserWalletId( + "011" + ) + ) + } returns Either.Right( + value = mockk { every { isLocked } returns true } + ) + every { TangemLogger.e("Error on getting user wallet") } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e("Error on getting user wallet") } + } + + @Test + fun `GIVEN error instead select wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf(WALLET_ID_KEY to "011") + val userWalletId = UserWalletId("011") + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { every { isLocked } returns false } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Left( + value = SelectWalletError.UnableToSelectUserWallet + ) + every { TangemLogger.e("Error on selecting user wallet") } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e("Error on selecting user wallet") } + } + + @Test + fun `GIVEN no crypto by wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + ) + val userWalletId = UserWalletId("011") + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null + val expectedErrorText = """ + Could not get crypto currency for + |- $NETWORK_ID_KEY: 123 + |- $TOKEN_ID_KEY: 321 + """.trimIndent() + every { TangemLogger.e(messageString = expectedErrorText) } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e(messageString = expectedErrorText) } + } + + @Test + fun `GIVEN multicurrency wallet WHEN handle deeplink THEN push new route`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + val expectedRoute = AppRoute.CurrencyDetails( + userWalletId = userWalletId, + currency = expectedCryptoCurrency, + ) + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns true + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { + appRouter.push( + route = expectedRoute, + onComplete = any(), + ) + } + } + + @Test + fun `GIVEN single currency wallet WHEN handle deeplink THEN push new route`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { + walletDeepLinkActionTrigger.selectWallet(userWalletId) + } + } + + @ParameterizedTest + @ValueSource(strings = ["swap_status_update", "onramp_status_update"]) + fun `GIVEN type WHEN handle deeplink THEN token details deeplink triggered`(type: String) = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777", + TRANSACTION_ID_KEY to "000", + TYPE_KEY to type, + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs + + createHandler(scope = this, queryParams) + advanceUntilIdle() + coVerify { + tokenDetailsDeepLinkActionTrigger.trigger("000") + } + } + + @ParameterizedTest + @ValueSource(strings = ["income_transaction", "promo", "unknown"]) + fun `GIVEN type WHEN handle deeplink THEN token details deeplink not triggered`(type: String) = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777", + TRANSACTION_ID_KEY to "000", + TYPE_KEY to type, + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs + + createHandler(scope = this, queryParams) + advanceUntilIdle() + coVerify(exactly = 0) { + tokenDetailsDeepLinkActionTrigger.trigger("000") + } + } + + @Test + fun `GIVEN multicurrency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns true + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { + cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency) + } just Runs + + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + verify { cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency) } + } + + @Test + fun `GIVEN single currency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + coEvery { + walletBalanceFetcher.invoke( + WalletBalanceFetcher.Params( + userWalletId = userWalletId, + ) + ) + } returns mockk() + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + coEvery { + walletBalanceFetcher.invoke( + WalletBalanceFetcher.Params( + userWalletId = userWalletId, + ) + ) + } + } + + private fun createHandler( + scope: CoroutineScope, + queryParams: Map, + isFromOnNewIntent: Boolean = false, + ) { + DefaultTokenDetailsDeepLinkHandler( + scope = scope, + queryParams = queryParams, + isFromOnNewIntent = isFromOnNewIntent, + appRouter = appRouter, + selectWalletUseCase = selectWalletUseCase, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, + tokenDetailsDeepLinkActionTrigger = tokenDetailsDeepLinkActionTrigger, + walletDeepLinkActionTrigger = walletDeepLinkActionTrigger, + analyticsEventHandler = analyticsEventHandler, + getUserWalletUseCase = getUserWalletUseCase, + walletBalanceFetcher = walletBalanceFetcher, + singleAccountListSupplier = singleAccountListSupplier, + getSelectedWalletSyncUseCase = getSelectedWalletSync, + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt new file mode 100644 index 0000000000..5fdf8f5045 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt @@ -0,0 +1,146 @@ +package com.tangem.feature.tokendetails.domain + +import arrow.core.none +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GetCurrencyWarningsUseCaseTest { + + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) + private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true) + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true) + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true) + private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true) + private val dispatchers: CoroutineDispatcherProvider = TestDispatchers(Dispatchers.Unconfined) + + private val userWalletId: UserWalletId = mockk(relaxed = true) + private val network: Network = mockk(relaxed = true) + private val derivationPath: Network.DerivationPath = mockk(relaxed = true) + private val accountStatusList: AccountStatusList = mockk(relaxed = true) + + private val useCase = GetCurrencyWarningsUseCase( + walletManagersFacade = walletManagersFacade, + currenciesRepository = currenciesRepository, + dispatchers = dispatchers, + currencyChecksRepository = currencyChecksRepository, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + dynamicAddressesRepository = dynamicAddressesRepository, + ) + + @BeforeEach + fun setUp() { + // Bypass coin-related warnings flow: force both coin and token lookups to None so + // the use case falls through to `SomeNetworksUnreachable` without needing real data. + mockkObject(CryptoCurrencyStatusOperations) + with(CryptoCurrencyStatusOperations) { + every { accountStatusList.getCoinStatus(any()) } returns none() + every { accountStatusList.getCryptoCurrencyStatus(any()) } returns none() + } + + every { singleAccountStatusListSupplier(any()) } returns flowOf(accountStatusList) + coEvery { currencyChecksRepository.getRentInfoWarning(any(), any()) } returns null + coEvery { currencyChecksRepository.getExistentialDeposit(any(), any()) } returns null + coEvery { currencyChecksRepository.getFeeResourceAmount(any(), any()) } returns null + coEvery { walletManagersFacade.getAssetRequirements(any(), any()) } returns null + } + + @AfterEach + fun tearDown() { + unmockkObject(CryptoCurrencyStatusOperations) + } + + @Test + fun `GIVEN token currency WHEN invoke THEN FundsFound is absent and probe flow is not queried`() = runTest { + // GIVEN + val token: CryptoCurrency.Token = mockk(relaxed = true) { + every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network + } + val currencyStatus = statusFor(token) + + // WHEN + val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first() + + // THEN + assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound) + verify(exactly = 0) { + dynamicAddressesRepository.hasFundsOnAdditionalAddresses(any(), any()) + } + } + + @Test + fun `GIVEN coin currency AND probe emits true WHEN invoke THEN FundsFound is present`() = runTest { + // GIVEN + val coin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network + } + every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(true) + val currencyStatus = statusFor(coin) + + // WHEN + val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first() + + // THEN + assertThat(result).contains(DynamicAddressesWarnings.FundsFound) + } + + @Test + fun `GIVEN coin currency AND probe emits false WHEN invoke THEN FundsFound is absent`() = runTest { + // GIVEN + val coin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network + } + every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(false) + val currencyStatus = statusFor(coin) + + // WHEN + val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first() + + // THEN + assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound) + } + + private fun statusFor(currency: CryptoCurrency): CryptoCurrencyStatus { + return mockk(relaxed = true) { + every { this@mockk.currency } returns currency + } + } + + private class TestDispatchers(dispatcher: CoroutineDispatcher) : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val single: CoroutineDispatcher = dispatcher + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt new file mode 100644 index 0000000000..059dacd1a9 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt @@ -0,0 +1,48 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class TokenDetailsNotificationConverterTest { + + private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val userWalletId: UserWalletId = mockk(relaxed = true) + + private val converter = TokenDetailsNotificationConverter( + userWalletId = userWalletId, + getUserWalletUseCase = getUserWalletUseCase, + clickIntents = clickIntents, + ) + + @Test + fun `GIVEN FundsFound warning WHEN convert THEN DynamicAddressesFundsFound notification is produced`() { + // WHEN + val result = converter.convert(setOf(DynamicAddressesWarnings.FundsFound)) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first()).isInstanceOf(TokenDetailsNotification.DynamicAddressesFundsFound::class.java) + } + + @Test + fun `GIVEN FundsFound warning WHEN learn more button clicked THEN click intent is invoked`() { + // GIVEN + val notification = converter.convert(setOf(DynamicAddressesWarnings.FundsFound)).first() + val button = notification.config.buttonsState as NotificationConfig.ButtonsState.SecondaryButtonConfig + + // WHEN + button.onClick() + + // THEN + verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt new file mode 100644 index 0000000000..52acfdab97 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt @@ -0,0 +1,135 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class InitializeWithCryptoCurrencyTransformerTest { + + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { name } returns TOKEN_NAME + every { symbol } returns TOKEN_SYMBOL + } + private val onBackClick: () -> Unit = mockk(relaxed = true) + + @Test + fun `GIVEN crypto currency WHEN transform THEN top bar title is Simple with token name`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = TOKEN_NAME)) + } + + @Test + fun `GIVEN crypto currency WHEN transform THEN subtitle is token symbol`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.subtitle).isEqualTo(stringReference(TOKEN_SYMBOL)) + } + + @Test + fun `GIVEN onBackClick callback WHEN top bar onBackClick invoked THEN callback is dispatched`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + result.topAppBarUM.onBackClick() + + // THEN + verify(exactly = 1) { onBackClick.invoke() } + } + + @Test + fun `GIVEN crypto currency WHEN transform THEN market price loading carries currency symbol`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.marketPriceBlockState) + .isEqualTo(MarketPriceBlockState.Loading(currencySymbol = TOKEN_SYMBOL)) + } + + @Test + fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = initialState() + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(state) + + // THEN — only top bar title/subtitle/onBackClick and marketPriceBlockState are touched + assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems) + assertThat(result.balanceBlockUM.actionButtons).isEqualTo(state.balanceBlockUM.actionButtons) + assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(state.balanceBlockUM.tokenBalanceTypeUM) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = mockk(relaxed = true), + ), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) + + private companion object { + const val TOKEN_NAME = "Tether" + const val TOKEN_SYMBOL = "USDT" + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt new file mode 100644 index 0000000000..8e8a26f034 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt @@ -0,0 +1,144 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SetBalanceLoadingTransformerTest { + + private val currencyIconState: CurrencyIconState = mockk(relaxed = true) + + @Test + fun `GIVEN any state WHEN transform THEN balance block is Loading`() { + // GIVEN + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java) + } + + @Test + fun `GIVEN currency icon state WHEN transform THEN Loading block carries that icon state`() { + // GIVEN + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.currencyIconState).isSameInstanceAs(currencyIconState) + } + + @Test + fun `GIVEN state with action buttons WHEN transform THEN action buttons are preserved`() { + // GIVEN + val buttons = persistentListOf( + TangemButtonUM( + text = stringReference("Test"), + onClick = {}, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + ) + val state = initialState(actionButtons = buttons) + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.balanceBlockUM.actionButtons).isEqualTo(buttons) + } + + @Test + fun `GIVEN any state WHEN transform THEN balance type is Single`() { + // GIVEN + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single) + } + + @Test + fun `GIVEN Content balance block WHEN transform THEN switches to Loading`() { + // GIVEN + val contentState = initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("1.0 BTC"), + displayFiatBalanceAll = stringReference("$50,000"), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ), + ) + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(contentState) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java) + } + + @Test + fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = initialState() + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + private fun initialState( + actionButtons: ImmutableList = persistentListOf(), + ): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = actionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt new file mode 100644 index 0000000000..a78c3ed430 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt @@ -0,0 +1,482 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.getTotalWithRewardsStakingBalance +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class SetBalanceTransformerTest { + + private val onToggleBalanceType: () -> Unit = mockk(relaxed = true) + private val appCurrency: AppCurrency = AppCurrency.Default + + private val network: Network = mockk(relaxed = true) { + every { rawId } returns "ethereum" + } + private val currency: CryptoCurrency = mockk(relaxed = true) { + every { this@mockk.network } returns this@SetBalanceTransformerTest.network + every { symbol } returns "ETH" + } + + @BeforeEach + fun setup() { + mockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance) + } + + @AfterEach + fun teardown() { + unmockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance) + } + + // region Status type → BalanceBlock type mapping + + @Test + fun `GIVEN Loading status WHEN transform THEN balance block is Loading`() { + // GIVEN + val status = createStatus(CryptoCurrencyStatus.Loading) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java) + } + + @Test + fun `GIVEN Loaded status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN NoQuote status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(noQuoteValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN NoAccount status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(noAccountValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN Custom status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(customValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN Unreachable status WHEN transform THEN balance block is Error`() { + // GIVEN + val status = createStatus( + CryptoCurrencyStatus.Unreachable(priceChange = null, fiatRate = null, networkAddress = null), + ) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java) + } + + @Test + fun `GIVEN NoAmount status WHEN transform THEN balance block is Error`() { + // GIVEN + val status = createStatus(CryptoCurrencyStatus.NoAmount(priceChange = null, fiatRate = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java) + } + + @Test + fun `GIVEN MissedDerivation status WHEN transform THEN balance block is Error`() { + // GIVEN + val status = createStatus(CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java) + } + + // endregion + + // region Action buttons & icon preservation + + @Test + fun `GIVEN any loaded status WHEN transform THEN action buttons are preserved`() { + // GIVEN + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.actionButtons).isEqualTo(initialState().balanceBlockUM.actionButtons) + } + + @Test + fun `GIVEN any loaded status WHEN transform THEN currency icon state is preserved`() { + // GIVEN + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.currencyIconState) + .isEqualTo(initialState().balanceBlockUM.currencyIconState) + } + + // endregion + + // region Staking / balance type + + @Test + fun `GIVEN loaded status without staking WHEN transform THEN balance type is Single`() { + // GIVEN + val status = createStatus(loadedValue(stakingBalance = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single) + } + + @Test + fun `GIVEN loaded status with staking WHEN transform THEN balance type is Multiple`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.tokenBalanceTypeUM).isInstanceOf(TokenBalanceTypeUM.Multiple::class.java) + } + + @Test + fun `GIVEN loaded status with staking WHEN transform THEN available balance types include ALL and AVAILABLE`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.availableTypes).containsExactly( + TokenBalanceTypeUM.Type.ALL, + TokenBalanceTypeUM.Type.AVAILABLE, + ) + } + + @Test + fun `GIVEN staking balance WHEN Multiple onSelect invoked THEN onToggleBalanceType is called`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + multiple.onSelect() + + // THEN + verify(exactly = 1) { onToggleBalanceType.invoke() } + } + + @Test + fun `GIVEN staking and previous Multiple type AVAILABLE WHEN transform THEN selected type is preserved`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + val prevContent = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple( + type = TokenBalanceTypeUM.Type.AVAILABLE, + availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE), + onSelect = {}, + ), + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference(""), + displayFiatBalanceAll = stringReference(""), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ) + val state = initialState().copy(balanceBlockUM = prevContent) + + // WHEN + val result = transformer.transform(state) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE) + } + + // endregion + + // region Balance flickering + + @Test + fun `GIVEN CACHE source WHEN transform THEN isBalanceFlickering is true`() { + // GIVEN + val sources = CryptoCurrencyStatus.Sources( + networkSource = StatusSource.CACHE, + quoteSource = StatusSource.CACHE, + stakingBalanceSource = StatusSource.CACHE, + ) + val status = createStatus(loadedValue(sources = sources)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.isBalanceFlickering).isTrue() + } + + @Test + fun `GIVEN ACTUAL source WHEN transform THEN isBalanceFlickering is false`() { + // GIVEN + val sources = CryptoCurrencyStatus.Sources( + networkSource = StatusSource.ACTUAL, + quoteSource = StatusSource.ACTUAL, + stakingBalanceSource = StatusSource.ACTUAL, + ) + val status = createStatus(loadedValue(sources = sources)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.isBalanceFlickering).isFalse() + } + + // endregion + + // region No staking → available balances + + @Test + fun `GIVEN loaded without staking WHEN transform THEN available balances are null`() { + // GIVEN + val status = createStatus(loadedValue(stakingBalance = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayCryptoBalanceAvailable).isNull() + assertThat(content.displayFiatBalanceAvailable).isNull() + } + + @Test + fun `GIVEN staking balance WHEN transform THEN available balances are not null`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayCryptoBalanceAvailable).isNotNull() + assertThat(content.displayFiatBalanceAvailable).isNotNull() + } + + // endregion + + // region Unrelated fields preserved + + @Test + fun `GIVEN any status WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = initialState() + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + // endregion + + private fun createTransformer(status: CryptoCurrencyStatus) = SetBalanceTransformer( + status = status, + appCurrency = appCurrency, + onToggleBalanceType = onToggleBalanceType, + ) + + private fun createStatus(value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue( + amount: BigDecimal = BigDecimal("10.5"), + fiatAmount: BigDecimal = BigDecimal("21000"), + fiatRate: BigDecimal = BigDecimal("2000"), + stakingBalance: StakingBalance? = null, + sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(), + ): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded( + amount = amount, + fiatAmount = fiatAmount, + fiatRate = fiatRate, + priceChange = BigDecimal("2.5"), + stakingBalance = stakingBalance, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = sources, + ) + + private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote( + amount = BigDecimal("5.0"), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + + private fun noAccountValue(): CryptoCurrencyStatus.NoAccount = CryptoCurrencyStatus.NoAccount( + amountToCreateAccount = BigDecimal("0.01"), + fiatAmount = BigDecimal.ZERO, + priceChange = null, + fiatRate = BigDecimal("2000"), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + + private fun customValue(): CryptoCurrencyStatus.Custom = CryptoCurrencyStatus.Custom( + amount = BigDecimal("100"), + fiatAmount = null, + fiatRate = null, + priceChange = null, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt new file mode 100644 index 0000000000..2e1d75e21c --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt @@ -0,0 +1,205 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SetTopBarTitleTransformerTest { + + private val tokenName = "Tether" + private val walletName = "My Wallet" + private val deviceIconUM: DeviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ) + private val cryptoCurrency: CryptoCurrency.Coin = mockk(relaxed = true) { + every { name } returns tokenName + } + + @Test + fun `GIVEN single wallet single account WHEN transform THEN Simple title`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = false, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName)) + } + + @Test + fun `GIVEN multiple wallets and single account WHEN transform THEN WithWallet title`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = true, + hasMultipleAccounts = false, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val expected = TitleState.WithWallet( + tokenName = tokenName, + walletName = walletName, + deviceIconUM = deviceIconUM, + ) + assertThat(result.topAppBarUM.titleState).isEqualTo(expected) + } + + @Test + fun `GIVEN single wallet and multiple accounts WHEN transform THEN WithAccount title`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = true, + account = stubAccount(), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val title = result.topAppBarUM.titleState + assertThat(title).isInstanceOf(TitleState.WithAccount::class.java) + assertThat((title as TitleState.WithAccount).tokenName).isEqualTo(tokenName) + } + + @Test + fun `GIVEN multiple wallets AND multiple accounts WHEN transform THEN WithAccount wins`() { + // GIVEN — design priority: account branch wins over wallet branch + val transformer = createTransformer( + hasMultipleWallets = true, + hasMultipleAccounts = true, + account = stubAccount(), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithAccount::class.java) + } + + @Test + fun `GIVEN multiple accounts but null account WHEN transform THEN falls back to wallet branch`() { + // GIVEN — race protection: hasMultipleAccounts=true but account not loaded yet + val transformer = createTransformer( + hasMultipleWallets = true, + hasMultipleAccounts = true, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithWallet::class.java) + } + + @Test + fun `GIVEN multiple accounts but null account AND single wallet WHEN transform THEN falls back to Simple`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = true, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName)) + } + + @Test + fun `GIVEN account with custom icon WHEN transform THEN icon is propagated`() { + // GIVEN + val account = stubAccount( + iconValue = CryptoPortfolioIcon.Icon.Star, + iconColor = CryptoPortfolioIcon.Color.Azure, + ) + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = true, + account = account, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val title = result.topAppBarUM.titleState as TitleState.WithAccount + val expectedIcon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ) + assertThat(title.accountIconUM).isEqualTo(expectedIcon) + } + + private fun createTransformer( + hasMultipleWallets: Boolean, + hasMultipleAccounts: Boolean, + account: Account.CryptoPortfolio?, + ) = SetTopBarTitleTransformer( + cryptoCurrency = cryptoCurrency, + hasMultipleWallets = hasMultipleWallets, + hasMultipleAccounts = hasMultipleAccounts, + walletName = walletName, + deviceIconUM = deviceIconUM, + account = account, + ) + + private fun stubAccount( + iconValue: CryptoPortfolioIcon.Icon = CryptoPortfolioIcon.Icon.Star, + iconColor: CryptoPortfolioIcon.Color = CryptoPortfolioIcon.Color.Azure, + ): Account.CryptoPortfolio { + val icon: CryptoPortfolioIcon = mockk { + every { value } returns iconValue + every { color } returns iconColor + } + return mockk { + every { accountName } returns AccountName.DefaultMain + every { this@mockk.icon } returns icon + } + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt new file mode 100644 index 0000000000..99be5d0fa3 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt @@ -0,0 +1,204 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class ToggleBalanceTypeTransformerTest { + + private val transformer = ToggleBalanceTypeTransformer() + + // region Toggle logic + + @Test + fun `GIVEN Multiple with ALL WHEN transform THEN type switches to AVAILABLE`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + + // WHEN + val result = transformer.transform(state) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE) + } + + @Test + fun `GIVEN Multiple with AVAILABLE WHEN transform THEN type switches to ALL`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.AVAILABLE) + + // WHEN + val result = transformer.transform(state) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL) + } + + @Test + fun `GIVEN Multiple with ALL WHEN transform twice THEN type returns to ALL`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + + // WHEN + val result = transformer.transform(transformer.transform(state)) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL) + } + + // endregion + + // region No-op cases + + @Test + fun `GIVEN Loading balance block WHEN transform THEN state is unchanged`() { + // GIVEN + val state = initialState() + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + @Test + fun `GIVEN Error balance block WHEN transform THEN state is unchanged`() { + // GIVEN + val state = initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Error( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + @Test + fun `GIVEN Content with Single balance type WHEN transform THEN state is unchanged`() { + // GIVEN + val state = initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("1.0 ETH"), + displayFiatBalanceAll = stringReference("$2,000"), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + // endregion + + // region Unrelated fields preserved + + @Test + fun `GIVEN any togglable state WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + @Test + fun `GIVEN togglable state WHEN transform THEN balance content fields besides type are preserved`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + val originalContent = state.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + + // WHEN + val result = transformer.transform(state) + + // THEN + val resultContent = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(resultContent.actionButtons).isEqualTo(originalContent.actionButtons) + assertThat(resultContent.currencyIconState).isEqualTo(originalContent.currencyIconState) + assertThat(resultContent.displayCryptoBalanceAll).isEqualTo(originalContent.displayCryptoBalanceAll) + assertThat(resultContent.displayFiatBalanceAll).isEqualTo(originalContent.displayFiatBalanceAll) + assertThat(resultContent.isBalanceFlickering).isEqualTo(originalContent.isBalanceFlickering) + } + + // endregion + + private fun stateWithContent(type: TokenBalanceTypeUM.Type): TokenDetailsUM { + return initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple( + type = type, + availableTypes = persistentListOf( + TokenBalanceTypeUM.Type.ALL, + TokenBalanceTypeUM.Type.AVAILABLE, + ), + onSelect = {}, + ), + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("10.5 ETH"), + displayFiatBalanceAll = stringReference("$21,000"), + displayCryptoBalanceAvailable = stringReference("9.0 ETH"), + displayFiatBalanceAvailable = stringReference("$18,000"), + isBalanceFlickering = false, + ), + ) + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt new file mode 100644 index 0000000000..add72c4956 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt @@ -0,0 +1,572 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings +import com.tangem.domain.tokens.model.warnings.HederaWarnings +import com.tangem.domain.tokens.model.warnings.KaspaWarnings +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class UpdateNotificationsTransformerTest { + + private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true) + + // region Mapping warnings to notifications + + @Test + fun `GIVEN empty warnings WHEN transform THEN notifications are empty`() { + // GIVEN + val transformer = createTransformer(warnings = emptySet()) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN SomeNetworksUnreachable WHEN transform THEN notification with id networks_unreachable is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("networks_unreachable") + } + + @Test + fun `GIVEN BalanceNotEnoughForFee WHEN transform THEN notification with id balance_not_enough_for_fee is created`() { + // GIVEN + val tokenCurrency: CryptoCurrency = mockk(relaxed = true) + val coinCurrency: CryptoCurrency = mockk(relaxed = true) { + io.mockk.every { network } returns mockk(relaxed = true) { + io.mockk.every { name } returns "Ethereum" + } + io.mockk.every { name } returns "Ethereum" + io.mockk.every { symbol } returns "ETH" + } + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.BalanceNotEnoughForFee( + tokenCurrency = tokenCurrency, + coinCurrency = coinCurrency, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("balance_not_enough_for_fee") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN CustomTokenNotEnoughForFee with null feeCurrency WHEN transform THEN notification has no buttons`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.CustomTokenNotEnoughForFee( + currency = currency, + feeCurrency = null, + networkName = "Ethereum", + feeCurrencyName = "Ethereum", + feeCurrencySymbol = "ETH", + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("custom_token_not_enough_for_fee") + assertThat(result.notifications.first().buttonsUM).isEmpty() + } + + @Test + fun `GIVEN BeaconChainShutdown WHEN transform THEN notification with id beacon_chain_shutdown is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.BeaconChainShutdown), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("beacon_chain_shutdown") + } + + @Test + fun `GIVEN MigrationMaticToPol WHEN transform THEN notification with id migration_matic_pol is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.MigrationMaticToPol), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("migration_matic_pol") + } + + @Test + fun `GIVEN MigrationClore WHEN transform THEN notification has button and id migration_clore`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.MigrationClore), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("migration_clore") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN HederaAssociateWarning WHEN transform THEN notification with button is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf(HederaWarnings.AssociateWarning(currency = currency)), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("hedera_associate") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN HederaAssociateWarningWithFee WHEN transform THEN notification with button is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + HederaWarnings.AssociateWarningWithFee( + currency = currency, + fee = BigDecimal("0.05"), + feeCurrencySymbol = "HBAR", + feeCurrencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("hedera_associate_fee") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN RequiredTrustline WHEN transform THEN notification with button is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.RequiredTrustline( + currency = currency, + currencySymbol = "XLM", + requiredAmount = BigDecimal("10"), + currencyDecimals = 7, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("required_trustline") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN KaspaIncompleteTransaction WHEN transform THEN notification with button and close is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + KaspaWarnings.IncompleteTransaction( + currency = currency, + amount = BigDecimal("100"), + currencySymbol = "KAS", + currencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("kaspa_incomplete") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + assertThat(result.notifications.first().onCloseClick).isNotNull() + } + + // endregion + + // region Skipped warnings + + @Test + fun `GIVEN ExistentialDeposit WHEN transform THEN notification is skipped`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.ExistentialDeposit( + currencyName = "Polkadot", + edStringValueWithSymbol = "1 DOT", + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN Rent WHEN transform THEN notification is skipped`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.Rent( + rent = BigDecimal("0.00001"), + exemptionAmount = BigDecimal("0.01"), + cryptoCurrency = mockk(relaxed = true), + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN UsedOutdatedDataWarning WHEN transform THEN notification is skipped`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.UsedOutdatedDataWarning), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + // endregion + + // region Message effect + + @Test + fun `GIVEN any mapped warning WHEN transform THEN messageEffect is None`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications.first().messageEffect).isEqualTo(TangemMessageEffect.None) + } + + // endregion + + // region Icon + + @Test + fun `GIVEN any mapped warning WHEN transform THEN iconUM is not null`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.BeaconChainShutdown), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications.first().iconUM).isNotNull() + } + + // endregion + + // region Multiple warnings + + @Test + fun `GIVEN multiple warnings with some skipped WHEN transform THEN only mapped warnings are in notifications`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.SomeNetworksUnreachable, + CryptoCurrencyWarning.BeaconChainShutdown, + CryptoCurrencyWarning.UsedOutdatedDataWarning, + CryptoCurrencyWarning.TopUpWithoutReserve, + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(2) + assertThat(result.notifications.map { it.id }).containsExactly( + "networks_unreachable", + "beacon_chain_shutdown", + ) + } + + // endregion + + // region Click callbacks + + @Test + fun `GIVEN HederaAssociateWarning WHEN button clicked THEN onAssociateClick is called`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf(HederaWarnings.AssociateWarning(currency = currency)), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onAssociateClick() } + } + + @Test + fun `GIVEN RequiredTrustline WHEN button clicked THEN onOpenTrustlineClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.RequiredTrustline( + currency = mockk(relaxed = true), + currencySymbol = "XLM", + requiredAmount = BigDecimal("10"), + currencyDecimals = 7, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onOpenTrustlineClick() } + } + + @Test + fun `GIVEN KaspaIncompleteTransaction WHEN retry clicked THEN onRetryIncompleteTransactionClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + KaspaWarnings.IncompleteTransaction( + currency = mockk(relaxed = true), + amount = BigDecimal("100"), + currencySymbol = "KAS", + currencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onRetryIncompleteTransactionClick() } + } + + @Test + fun `GIVEN KaspaIncompleteTransaction WHEN close clicked THEN onDismissIncompleteTransactionClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + KaspaWarnings.IncompleteTransaction( + currency = mockk(relaxed = true), + amount = BigDecimal("100"), + currencySymbol = "KAS", + currencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().onCloseClick!!.invoke() + + // THEN + verify(exactly = 1) { clickIntents.onDismissIncompleteTransactionClick() } + } + + @Test + fun `GIVEN DynamicAddressesFundsFound WHEN transform THEN notification with learn more button is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(DynamicAddressesWarnings.FundsFound), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("dynamic_addresses_funds_found") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN DynamicAddressesFundsFound WHEN button clicked THEN onDynamicAddressesFundsFoundLearnMoreClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(DynamicAddressesWarnings.FundsFound), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() } + } + + @Test + fun `GIVEN MigrationClore WHEN button clicked THEN onCloreMigrationClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.MigrationClore), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onCloreMigrationClick() } + } + + @Test + fun `GIVEN BalanceNotEnoughForFee WHEN buy button clicked THEN onBuyCoinClick is called`() { + // GIVEN + val coinCurrency: CryptoCurrency = mockk(relaxed = true) { + io.mockk.every { network } returns mockk(relaxed = true) { + io.mockk.every { name } returns "Ethereum" + } + io.mockk.every { name } returns "Ethereum" + io.mockk.every { symbol } returns "ETH" + } + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.BalanceNotEnoughForFee( + tokenCurrency = mockk(relaxed = true), + coinCurrency = coinCurrency, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onBuyCoinClick(coinCurrency) } + } + + // endregion + + // region State preservation + + @Test + fun `GIVEN any warnings WHEN transform THEN unrelated state fields are preserved`() { + // GIVEN + val state = initialState() + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + // endregion + + private fun createTransformer(warnings: Set) = UpdateNotificationsTransformer( + warnings = warnings, + clickIntents = clickIntents, + ) + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = "Tether"), + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt new file mode 100644 index 0000000000..cc0a370391 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt @@ -0,0 +1,137 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingOption +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class UpdateStakingNotificationTransformerTest { + + private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true) + + @Test + fun `GIVEN Unavailable WHEN transform THEN earnBlockState is null`() { + val transformer = createTransformer( + availability = StakingAvailability.Unavailable, + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isNull() + } + + @Test + fun `GIVEN TemporaryUnavailable WHEN transform THEN TemporaryUnavailable`() { + val transformer = createTransformer( + availability = StakingAvailability.TemporaryUnavailable, + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result.earnBlockState as EarnBlockUM.Content + assertThat(content.iconUM).isInstanceOf(EarnBlockUM.IconUM.Plain::class.java) + assertThat(content.trailingUM).isNull() + } + + @Test + fun `GIVEN Available without entryInfo AND no staked WHEN transform THEN null`() { + val transformer = createTransformer( + availability = availableOption(BigDecimal("4.2")), + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isNull() + } + + @Test + fun `GIVEN Available with entryInfo AND no staked WHEN transform THEN Available`() { + val transformer = createTransformer( + availability = availableOption(BigDecimal("4.2")), + entryInfo = StakingEntryInfo(tokenSymbol = "SOL"), + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result.earnBlockState as EarnBlockUM.Content + assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) + } + + private fun createTransformer( + availability: StakingAvailability, + entryInfo: StakingEntryInfo?, + ) = UpdateStakingNotificationTransformer( + cryptoCurrencyStatus = buildStatus(), + stakingAvailability = availability, + stakingEntryInfo = entryInfo, + appCurrency = AppCurrency.Default, + clickIntents = clickIntents, + ) + + private fun buildStatus(): CryptoCurrencyStatus { + val network = mockk(relaxed = true) { + every { rawId } returns "solana" + every { isTestnet } returns false + } + val currency = mockk(relaxed = true) { + every { symbol } returns "SOL" + every { decimals } returns 9 + every { this@mockk.network } returns network + every { id.isCoin } returns true + } + val stakingBalance = mockk(relaxed = true) + val value = mockk(relaxed = true) { + every { this@mockk.stakingBalance } returns stakingBalance + every { fiatRate } returns BigDecimal.ONE + every { yieldSupplyStatus } returns null + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + private fun availableOption(apy: BigDecimal): StakingAvailability.Available { + val option = mockk(relaxed = true) { + every { this@mockk.apy } returns apy + } + return StakingAvailability.Available(option = option) + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = "Solana"), + subtitle = stringReference("Solana network"), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), + earnBlockState = null, + marketPriceBlockState = mockk(relaxed = true), + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt new file mode 100644 index 0000000000..f12747803f --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt @@ -0,0 +1,215 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.card.CardTypesResolver +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UpdateTopBarMenuTransformerTest { + + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) + private val cardTypesResolver: CardTypesResolver = mockk(relaxed = true) + private val onGenerateExtendedKey: () -> Unit = mockk(relaxed = true) + private val onHideClick: () -> Unit = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + mockkStatic(UserWallet.Cold::cardTypesResolver) + every { coldWallet.cardTypesResolver } returns cardTypesResolver + } + + @AfterEach + fun tearDown() { + unmockkStatic(UserWallet.Cold::cardTypesResolver) + } + + @Test + fun `GIVEN cold wallet AND single wallet with token WHEN transform THEN menu is empty`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns true + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).isEmpty() + } + + @Test + fun `GIVEN cold wallet AND multi-wallet WHEN transform THEN Hide item is the only one`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = false, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + + result.topAppBarUM.menuItems.single().onClick() + verify(exactly = 1) { onHideClick.invoke() } + verify(exactly = 0) { onGenerateExtendedKey.invoke() } + } + + @Test + fun `GIVEN hot wallet WHEN transform THEN Hide item is shown regardless of single-wallet flag`() { + // GIVEN — flag is read only for cold wallets, so no stub needed for hotWallet + val transformer = createTransformer( + userWallet = hotWallet, + hasDerivations = false, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + } + + @Test + fun `GIVEN xPub supported AND derivations exist WHEN transform THEN both items are shown`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN — Generate xPub first, Hide token second + assertThat(result.topAppBarUM.menuItems).hasSize(2) + } + + @Test + fun `GIVEN xPub supported but no derivations WHEN transform THEN xPub item is hidden`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = false, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + } + + @Test + fun `GIVEN derivations exist but xPub unsupported WHEN transform THEN xPub item is hidden`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + } + + @Test + fun `GIVEN any state WHEN transform THEN unrelated state fields are preserved`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val state = initialState() + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = false, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(state) + + // THEN — only menuItems is touched + assertThat(result.topAppBarUM.titleState).isEqualTo(state.topAppBarUM.titleState) + assertThat(result.topAppBarUM.subtitle).isEqualTo(state.topAppBarUM.subtitle) + assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + } + + @Test + fun `GIVEN callbacks WHEN menu items invoked THEN callbacks are dispatched`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + result.topAppBarUM.menuItems.forEach { it.onClick() } + + // THEN + verify(exactly = 1) { onGenerateExtendedKey.invoke() } + verify(exactly = 1) { onHideClick.invoke() } + } + + private fun createTransformer( + userWallet: UserWallet, + hasDerivations: Boolean, + isXPubSupported: Boolean, + ) = UpdateTopBarMenuTransformer( + userWallet = userWallet, + hasDerivations = hasDerivations, + isXPubSupported = isXPubSupported, + onGenerateExtendedKey = onGenerateExtendedKey, + onHideClick = onHideClick, + ) + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = "Tether"), + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/txhistory/api/build.gradle.kts b/features/txhistory/api/build.gradle.kts index 269986e650..33ac80bd64 100644 --- a/features/txhistory/api/build.gradle.kts +++ b/features/txhistory/api/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { /** Compose */ implementation(deps.compose.runtime) implementation(deps.compose.foundation) + implementation(deps.compose.ui.tooling) /** Other */ implementation(deps.kotlin.immutable.collections) diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt index 470bb333f5..a18865d1d5 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt @@ -14,6 +14,8 @@ interface TxHistoryComponent { val txHistoryState: StateFlow + fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) + fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) data class Params( diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt index ad33da1dc0..dca5fc31be 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt @@ -15,6 +15,7 @@ sealed interface TxHistoryUM { TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_1")), TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_2")), TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_3")), + TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_4")), ) } diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt index aafd528437..56637babd7 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt @@ -1,139 +1,214 @@ package com.tangem.features.txhistory.ui +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.tangem.core.ui.components.list.InfiniteListHandler -import com.tangem.core.ui.components.transactions.PendingTxsBlock -import com.tangem.core.ui.components.transactions.Transaction -import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle -import com.tangem.core.ui.components.transactions.TxHistoryTitle +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.txhistory.entity.TxHistoryUM -private const val LOAD_ITEMS_BUFFER = 20 +private val LoadingTitleShimmerWidth = 52.dp +private val LoadingPrimaryShimmerWidth = 110.dp +private val LoadingSecondaryShimmerWidth = 52.dp +private val LoadingEndTopShimmerWidth = 107.dp +private val LoadingEndBottomShimmerWidth = 52.dp + +private const val LOADING_TRANSACTION_MIN_ALPHA = 0.1f fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) { when (state) { is TxHistoryUM.Content -> contentItems(listState, state) - is TxHistoryUM.Empty -> nonContentItem(state = EmptyTransactionsBlockState.Empty(state.onExploreClick)) - is TxHistoryUM.Error -> nonContentItem( - state = EmptyTransactionsBlockState.FailedToLoad( - onReload = state.onReloadClick, - onExplore = state.onExploreClick, - ), - ) + is TxHistoryUM.Empty -> emptyItem(state) + is TxHistoryUM.Error -> errorItem(state) is TxHistoryUM.Loading -> loadingItems(state) - is TxHistoryUM.NotSupported -> { - if (state.pendingTransactions.isNotEmpty()) { - item(key = "PendingTxsBlock", contentType = "PendingTxsBlock") { - PendingTxsBlock(pendingTxs = state.pendingTransactions, isBalanceHidden = state.isBalanceHidden) - } - } - - nonContentItem( - state = EmptyTransactionsBlockState.NotImplemented(onExplore = state.onExploreClick), - ) - } + is TxHistoryUM.NotSupported -> notSupportedItem(state) } } -private fun LazyListScope.nonContentItem(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { - item(key = state::class.java, contentType = state::class.java) { - EmptyTransactionBlock( - state = state, - modifier = modifier - .animateItem(fadeInSpec = null, fadeOutSpec = null) - .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) - .fillMaxWidth(), - ) +@Suppress("UNUSED_PARAMETER") +private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) { + item(key = "tx_history_content", contentType = "tx_history_content") { + TxHistoryContentBlock(state = state) + } +} + +private fun LazyListScope.emptyItem(state: TxHistoryUM.Empty) { + item(key = "tx_history_empty", contentType = "tx_history_empty") { + TxHistoryEmptyBlock(state = state) + } +} + +private fun LazyListScope.errorItem(state: TxHistoryUM.Error) { + item(key = "tx_history_error", contentType = "tx_history_error") { + TxHistoryErrorBlock(state = state) } } private fun LazyListScope.loadingItems(state: TxHistoryUM.Loading) { - itemsIndexed( - items = state.items, - key = { _, item -> - when (item) { - is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey - is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() - is TxHistoryUM.TxHistoryItemUM.Transaction -> - item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() - } - }, - contentType = { _, item -> item::class.java }, - itemContent = { index, item -> - TxHistoryListItem( - state = item, - isBalanceHidden = true, - modifier = Modifier.roundedShapeItemDecoration( - currentIndex = index, - lastIndex = state.items.lastIndex, - ), - ) - }, + item(key = "tx_history_loading", contentType = "tx_history_loading") { + TxHistoryLoadingBlock(state = state) + } +} + +private fun LazyListScope.notSupportedItem(state: TxHistoryUM.NotSupported) { + item(key = "tx_history_not_supported", contentType = "tx_history_not_supported") { + TxHistoryNotSupportedBlock(state = state) + } +} + +@Suppress("UNUSED_PARAMETER") +@Composable +private fun TxHistoryContentBlock(state: TxHistoryUM.Content, modifier: Modifier = Modifier) { + // TODO [REDACTED_TASK_KEY] redesign Content state +} + +@Composable +private fun TxHistoryEmptyBlock(state: TxHistoryUM.Empty, modifier: Modifier = Modifier) { + EmptyTransactionBlock( + state = EmptyTransactionsBlockState.Empty( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_compass_24, + ), + modifier = modifier, ) } -private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) { - itemsIndexed( - items = state.items, - key = { _, item -> - when (item) { - is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey - is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() - is TxHistoryUM.TxHistoryItemUM.Transaction -> - item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() - } - }, - contentType = { _, item -> item::class.java }, - itemContent = { index, item -> - TxHistoryListItem( - state = item, - isBalanceHidden = state.isBalanceHidden, - modifier = Modifier.roundedShapeItemDecoration( - currentIndex = index, - lastIndex = state.items.lastIndex, - ), - ) - }, +@Composable +private fun TxHistoryErrorBlock(state: TxHistoryUM.Error, modifier: Modifier = Modifier) { + EmptyTransactionBlock( + state = EmptyTransactionsBlockState.FailedToLoad( + onReload = state.onReloadClick, + onExplore = state.onExploreClick, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_compass_24, + ), + modifier = modifier, ) - item { - InfiniteListHandler( - listState = listState, - buffer = LOAD_ITEMS_BUFFER, - onLoadMore = state.loadMore, - ) +} + +@Composable +private fun TxHistoryLoadingBlock(state: TxHistoryUM.Loading, modifier: Modifier = Modifier) { + val transactionCount = state.items.count { it is TxHistoryUM.TxHistoryItemUM.Transaction } + Column(modifier = modifier.fillMaxWidth()) { + var transactionIndex = 0 + state.items.forEach { item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.Title -> TxHistoryLoadingTitle() + is TxHistoryUM.TxHistoryItemUM.Transaction -> { + val fraction = if (transactionCount <= 1) { + 0f + } else { + transactionIndex.toFloat() / (transactionCount - 1) + } + val alpha = lerp(start = 1f, stop = LOADING_TRANSACTION_MIN_ALPHA, fraction = fraction) + TxHistoryLoadingTransaction(modifier = Modifier.alpha(alpha)) + transactionIndex++ + } + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> Unit + } + } } } @Composable -internal fun TxHistoryListItem( - state: TxHistoryUM.TxHistoryItemUM, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, -) { - when (state) { - is TxHistoryUM.TxHistoryItemUM.GroupTitle -> { - TxHistoryGroupTitle(config = state.legacyGroupTitle, modifier = modifier) - } - is TxHistoryUM.TxHistoryItemUM.Title -> { - TxHistoryTitle(onExploreClick = state.onExploreClick, modifier = modifier) - } - is TxHistoryUM.TxHistoryItemUM.Transaction -> { - Transaction( - state = state.state, - isBalanceHidden = isBalanceHidden, - modifier = modifier, +private fun TxHistoryLoadingTitle(modifier: Modifier = Modifier) { + RectangleShimmer( + modifier = modifier + .padding( + top = TangemTheme.dimens2.x6, + bottom = TangemTheme.dimens2.x3, + start = TangemTheme.dimens2.x4, ) - } + .size(width = LoadingTitleShimmerWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) +} + +@Composable +private fun TxHistoryLoadingTransaction(modifier: Modifier = Modifier) { + TangemRowContainer( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x4, + vertical = TangemTheme.dimens2.x3, + ), + content = { + CircleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10), + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .size(width = LoadingPrimaryShimmerWidth, height = TangemTheme.dimens2.x5), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .size(width = LoadingSecondaryShimmerWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.END_TOP) + .size(width = LoadingEndTopShimmerWidth, height = TangemTheme.dimens2.x5), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.END_BOTTOM) + .size(width = LoadingEndBottomShimmerWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) + }, + ) +} + +@Composable +private fun TxHistoryNotSupportedBlock(state: TxHistoryUM.NotSupported, modifier: Modifier = Modifier) { + EmptyTransactionBlock( + state = EmptyTransactionsBlockState.NotImplemented( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_compass_24, + ), + modifier = modifier, + ) +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TxHistoryLoadingBlock_Preview() { + TangemThemePreviewRedesign { + TxHistoryLoadingBlock( + state = TxHistoryUM.Loading( + isBalanceHidden = false, + onExploreClick = {}, + ), + ) } -} \ No newline at end of file +} +// endregion Preview \ No newline at end of file diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContentLegacy.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContentLegacy.kt new file mode 100644 index 0000000000..f57b597472 --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContentLegacy.kt @@ -0,0 +1,150 @@ +package com.tangem.features.txhistory.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.R +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.components.transactions.PendingTxsBlock +import com.tangem.core.ui.components.transactions.Transaction +import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle +import com.tangem.core.ui.components.transactions.TxHistoryTitle +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlockLegacy +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.txhistory.entity.TxHistoryUM + +private const val LOAD_ITEMS_BUFFER = 20 + +fun LazyListScope.txHistoryItemsLegacy(listState: LazyListState, state: TxHistoryUM) { + when (state) { + is TxHistoryUM.Content -> contentItems(listState, state) + is TxHistoryUM.Empty -> nonContentItem( + state = EmptyTransactionsBlockState.Empty( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ) + is TxHistoryUM.Error -> nonContentItem( + state = EmptyTransactionsBlockState.FailedToLoad( + onReload = state.onReloadClick, + onExplore = state.onExploreClick, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ) + is TxHistoryUM.Loading -> loadingItems(state) + is TxHistoryUM.NotSupported -> { + if (state.pendingTransactions.isNotEmpty()) { + item(key = "PendingTxsBlock", contentType = "PendingTxsBlock") { + PendingTxsBlock(pendingTxs = state.pendingTransactions, isBalanceHidden = state.isBalanceHidden) + } + } + + nonContentItem( + state = EmptyTransactionsBlockState.NotImplemented( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ) + } + } +} + +private fun LazyListScope.nonContentItem(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { + item(key = state::class.java, contentType = state::class.java) { + EmptyTransactionBlockLegacy( + state = state, + modifier = modifier + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) + } +} + +private fun LazyListScope.loadingItems(state: TxHistoryUM.Loading) { + itemsIndexed( + items = state.items, + key = { _, item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey + is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() + is TxHistoryUM.TxHistoryItemUM.Transaction -> + item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() + } + }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + TxHistoryListItemLegacy( + state = item, + isBalanceHidden = true, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + ), + ) + }, + ) +} + +private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) { + itemsIndexed( + items = state.items, + key = { _, item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey + is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() + is TxHistoryUM.TxHistoryItemUM.Transaction -> + item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() + } + }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + TxHistoryListItemLegacy( + state = item, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + ), + ) + }, + ) + item { + InfiniteListHandler( + listState = listState, + buffer = LOAD_ITEMS_BUFFER, + onLoadMore = state.loadMore, + ) + } +} + +@Composable +internal fun TxHistoryListItemLegacy( + state: TxHistoryUM.TxHistoryItemUM, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> { + TxHistoryGroupTitle(config = state.legacyGroupTitle, modifier = modifier) + } + is TxHistoryUM.TxHistoryItemUM.Title -> { + TxHistoryTitle(onExploreClick = state.onExploreClick, modifier = modifier) + } + is TxHistoryUM.TxHistoryItemUM.Transaction -> { + Transaction( + state = state.state, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + } + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt index 907f834388..7e1db0ee54 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.txhistory.entity.TxHistoryUM import com.tangem.features.txhistory.model.TxHistoryModel import com.tangem.features.txhistory.ui.txHistoryItems +import com.tangem.features.txhistory.ui.txHistoryItemsLegacy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,6 +23,10 @@ internal class DefaultTxHistoryComponent @AssistedInject constructor( override val txHistoryState: StateFlow get() = model.uiState + override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) { + txHistoryItemsLegacy(listState, state) + } + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) { txHistoryItems(listState, state) } diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index ec188e4f5e..073dc92b0f 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -50,7 +50,7 @@ dependencies { implementation(projects.domain.settings) implementation(projects.domain.notifications.models) implementation(projects.domain.notifications) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) /* AndroidX */ implementation(deps.androidx.fragment.ktx) @@ -73,7 +73,6 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) /** Tangem libraries */ implementation(tangemDeps.hot.core) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 5ac502f555..2eb12d6343 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -37,7 +37,7 @@ import com.tangem.domain.nft.EnableWalletNFTUseCase import com.tangem.domain.nft.GetWalletNFTEnabledUseCase import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.settings.repositories.PermissionRepository -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.analytics.Settings import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction @@ -90,7 +90,7 @@ internal class WalletSettingsModel @Inject constructor( private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val singleAccountListSupplier: SingleAccountListSupplier, private val accountListSortingSaver: AccountListSortingSaver, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -256,8 +256,8 @@ internal class WalletSettingsModel @Inject constructor( val userWallet = getUserWalletUseCase(params.userWalletId) .getOrNull() - if (userWallet is UserWallet.Hot && hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.cancel(params.userWalletId) + if (userWallet is UserWallet.Hot && hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.cancel(params.userWalletId) } val hasUserWallets = deleteWalletUseCase(params.userWalletId).getOrElse { error -> diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt index 1bb167f64c..f4d741b7fe 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt @@ -7,7 +7,5 @@ package com.tangem.features.wallet.featuretoggles */ interface WalletFeatureToggles { - val isWalletReorderFeatureEnabled: Boolean - - val isMainScreenQrScanningEnabled: Boolean + val isAddAndManageTokensEnabled: Boolean } \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index d612025527..0f05b2b6c7 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -41,7 +41,6 @@ dependencies { implementation(deps.googlePlay.review) implementation(deps.jodatime) implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) implementation(tangemDeps.hot.core) implementation(tangemDeps.card.core) implementation(tangemDeps.blockchain) @@ -125,9 +124,11 @@ dependencies { implementation(projects.domain.yieldSupply.models) implementation(projects.domain.appTheme) implementation(projects.domain.appTheme.models) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) /** Feature Apis */ + implementation(projects.features.commonFeatures.api) + implementation(projects.features.account.api) implementation(projects.features.details.api) implementation(projects.features.hotWallet.api) implementation(projects.features.manageTokens.api) @@ -146,7 +147,6 @@ dependencies { implementation(projects.features.kyc.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.yieldSupply.api) - implementation(projects.features.tangempay.details.api) implementation(projects.features.feed.api) implementation(projects.features.promoBanners.api) implementation(projects.features.tangempay.main.api) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt new file mode 100644 index 0000000000..22715d7bb5 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt @@ -0,0 +1,67 @@ +package com.tangem.feature.wallet.child.managetokens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.child.managetokens.model.AddAndManageModel +import com.tangem.feature.wallet.child.managetokens.ui.AddAndManageBottomSheetContent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import kotlinx.serialization.builtins.serializer + +internal class AddAndManageBottomSheetComponent( + appComponentContext: AppComponentContext, + private val params: Params, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: AddAndManageModel = getOrCreateModel(params) + + private val portfolioSelectorSlot = childSlot( + source = model.portfolioSelectorNavigation, + serializer = Unit.serializer(), + handleBackButton = false, + childFactory = { _, context -> portfolioSelectorChild(context) }, + ) + + private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent = + portfolioSelectorComponentFactory.create( + context = childByContext(componentContext), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = model.portfolioFetcher, + controller = model.portfolioSelectorController, + bsCallback = model.portfolioSelectorCallback, + ), + ) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val portfolioSelectorSlot by portfolioSelectorSlot.subscribeAsState() + + AddAndManageBottomSheetContent( + onAddTokensClick = model::onAddTokensClick, + onOrganizeTokensClick = model::onOrganizeTokensClick, + onDismiss = ::dismiss, + ) + + portfolioSelectorSlot.child?.instance?.BottomSheet() + } + + data class Params( + val userWalletId: UserWalletId, + val onDismiss: () -> Unit, + val onOrganizeTokensClick: () -> Unit, + val onManageTokensClick: (AccountId) -> Unit, + ) +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt similarity index 52% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt index d28a1c305e..5ce1fdfab7 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt @@ -1,8 +1,8 @@ -package com.tangem.features.account.selector.di +package com.tangem.feature.wallet.child.managetokens.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.account.selector.PortfolioSelectorModel +import com.tangem.feature.wallet.child.managetokens.model.AddAndManageModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -11,10 +11,10 @@ import dagger.multibindings.IntoMap @Module @InstallIn(ModelComponent::class) -internal interface PortfolioSelectorModule { +internal interface AddAndManageModule { @Binds @IntoMap - @ClassKey(PortfolioSelectorModel::class) - fun portfolioSelectorModel(model: PortfolioSelectorModel): Model + @ClassKey(AddAndManageModel::class) + fun bindAddAndManageModel(model: AddAndManageModel): Model } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt new file mode 100644 index 0000000000..9600a357f8 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt @@ -0,0 +1,83 @@ +package com.tangem.feature.wallet.child.managetokens.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.models.account.AccountId +import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class AddAndManageModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val portfolioFetcherFactory: PortfolioFetcher.Factory, + val portfolioSelectorController: PortfolioSelectorController, +) : Model() { + + private val params = paramsContainer.require() + + val portfolioSelectorNavigation: SlotNavigation = SlotNavigation() + + val portfolioFetcher: PortfolioFetcher by lazy { + portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.Wallet(params.userWalletId), + scope = modelScope, + ) + } + + val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback { + override val onDismiss: () -> Unit = { portfolioSelectorNavigation.dismiss() } + override val onBack: () -> Unit = { portfolioSelectorNavigation.dismiss() } + } + + init { + observeAccountSelection() + } + + fun onAddTokensClick() { + modelScope.launch { + val data = portfolioFetcher.data.first() + val isSingleAccount = data.isSingleChoice(params.userWalletId) + + if (isSingleAccount) { + val mainAccountId = data.balances[params.userWalletId] + ?.accountsBalance + ?.mainAccount + ?.accountId + ?: AccountId.forMainCryptoPortfolio(params.userWalletId) + + params.onDismiss() + params.onManageTokensClick(mainAccountId) + } else { + portfolioSelectorNavigation.activate(Unit) + } + } + } + + fun onOrganizeTokensClick() { + params.onDismiss() + params.onOrganizeTokensClick() + } + + private fun observeAccountSelection() { + modelScope.launch { + portfolioSelectorController.selectedAccount.collect { accountId -> + if (accountId != null) { + portfolioSelectorNavigation.dismiss() + params.onDismiss() + params.onManageTokensClick(accountId) + } + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/ui/AddAndManageBottomSheetContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/ui/AddAndManageBottomSheetContent.kt new file mode 100644 index 0000000000..5534c0cb4f --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/ui/AddAndManageBottomSheetContent.kt @@ -0,0 +1,159 @@ +package com.tangem.feature.wallet.child.managetokens.ui + +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.CircleShape +import androidx.compose.material3.Icon +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.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.res.R as ResR +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +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.TangemThemePreview + +@Composable +internal fun AddAndManageBottomSheetContent( + onAddTokensClick: () -> Unit, + onOrganizeTokensClick: () -> Unit, + onDismiss: () -> Unit, +) { + val config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = AddAndManageBottomSheetConfigContent, + ) + + TangemModalBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.primary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(ResR.string.main_add_and_manage_tokens), + endIconRes = R.drawable.ic_close_24, + onEndClick = onDismiss, + ) + }, + content = { + AddAndManageContent( + onAddTokensClick = onAddTokensClick, + onOrganizeTokensClick = onOrganizeTokensClick, + ) + }, + ) +} + +@Composable +private fun AddAndManageContent(onAddTokensClick: () -> Unit, onOrganizeTokensClick: () -> Unit) { + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + AddAndManageRow( + iconRes = R.drawable.ic_plus_24, + title = ResR.string.add_and_manage_sheet_manage_title, + subtitle = ResR.string.add_and_manage_sheet_manage_subtitle, + onClick = onAddTokensClick, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = 0, + lastIndex = 1, + addDefaultPadding = false, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + AddAndManageRow( + iconRes = R.drawable.ic_filter_default_24, + title = ResR.string.add_and_manage_sheet_organize_title, + subtitle = ResR.string.add_and_manage_sheet_organize_subtitle, + onClick = onOrganizeTokensClick, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = 1, + lastIndex = 1, + addDefaultPadding = false, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } +} + +@Composable +private fun AddAndManageRow( + iconRes: Int, + title: Int, + subtitle: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f)), + ) { + Icon( + modifier = Modifier.size(18.dp), + painter = rememberVectorPainter(ImageVector.vectorResource(id = iconRes)), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResourceSafe(id = title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = stringResourceSafe(id = subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +private object AddAndManageBottomSheetConfigContent : TangemBottomSheetConfigContent + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AddAndManageBottomSheetContent_Preview() { + TangemThemePreview { + AddAndManageContent( + onAddTokensClick = {}, + onOrganizeTokensClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt index cd6c995427..fbb6c4d0a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt @@ -1,6 +1,6 @@ package com.tangem.feature.wallet.child.organizetokens.model.converter.items -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt index c1cfa0e69b..cff0b79404 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt @@ -3,7 +3,7 @@ package com.tangem.feature.wallet.child.organizetokens.model.converter.items import androidx.compose.ui.text.SpanStyle import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.internal.TangemRowTailUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index f15fbdfb75..d6f6a2e7bc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -23,7 +23,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableDialogComponent import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.feature.wallet.child.tokenActions.DefaultTokenActionsComponent import com.tangem.feature.wallet.child.tokenActions.TokenActionsComponent import com.tangem.feature.wallet.child.wallet.model.WalletModel @@ -64,6 +66,7 @@ internal class WalletComponent @AssistedInject constructor( private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory, private val tokenActionsComponentFactory: TokenActionsComponent.Factory, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, private val designFeatureToggles: DesignFeatureToggles, ) : ComposableContentComponent, AppComponentContext by appComponentContext { @@ -178,6 +181,25 @@ internal class WalletComponent @AssistedInject constructor( ), ) } + is WalletDialogConfig.AddAndManage -> { + AddAndManageBottomSheetComponent( + appComponentContext = childByContext(componentContext), + params = AddAndManageBottomSheetComponent.Params( + userWalletId = dialogConfig.userWalletId, + onDismiss = model.innerWalletRouter.dialogNavigation::dismiss, + onOrganizeTokensClick = { + model.innerWalletRouter.openOrganizeTokensScreen(dialogConfig.userWalletId) + }, + onManageTokensClick = { accountId -> + model.innerWalletRouter.openManageTokensScreen( + accountId = accountId, + source = AppRoute.ManageTokens.Source.WALLET, + ) + }, + ), + portfolioSelectorComponentFactory = portfolioSelectorComponentFactory, + ) + } is WalletDialogConfig.OrganizeTokens -> { OrganizeTokensComponent( appComponentContext = childByContext(componentContext), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 89446f2de8..6bc6f97a27 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -14,12 +14,13 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore -import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.model.AppThemeMode +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.* @@ -27,7 +28,6 @@ import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUse import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.QrResultSource import com.tangem.domain.qrscanning.models.QrSendTarget @@ -38,8 +38,6 @@ import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase -import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.domain.wallets.usecase.* import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents @@ -62,16 +60,15 @@ import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSend import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedCallbacks import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.* +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.* import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val TANGEM_PAY_UPDATE_INTERVAL = 60_000L @@ -109,7 +106,6 @@ internal class WalletModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase, private val tangemPayOnboardingRepository: OnboardingRepository, - private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase, private val getAppThemeModeUseCase: GetAppThemeModeUseCase, private val trackingContextProxy: TrackingContextProxy, private val singleAccountListSupplier: SingleAccountListSupplier, @@ -118,15 +114,13 @@ internal class WalletModel @Inject constructor( private val appsFlyerStore: AppsFlyerStore, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getWalletIconUseCase: GetWalletIconUseCase, - private val walletFeatureToggles: WalletFeatureToggles, private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val wcPairService: WcPairService, private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase, private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val uiMessageSender: UiMessageSender, private val hotWalletFeatureToggles: HotWalletFeatureToggles, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -159,7 +153,7 @@ internal class WalletModel @Inject constructor( subscribeTangemPayOnWalletState() subscribeToMainScreenQrScanning() enableNotificationsIfNeeded() - applyPendingTokenSyncs() + applyPendingAssetsDiscovery() clickIntents.initialize(innerWalletRouter, modelScope) @@ -438,17 +432,14 @@ internal class WalletModel @Inject constructor( if (isShouldLaunchPeriodicUpdate) { updateTangemPayJobHolder.cancel() modelScope.launch { - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) while (isActive) { delay(TANGEM_PAY_UPDATE_INTERVAL) - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } }.saveIn(updateTangemPayJobHolder) } else { // Don't refresh customer info periodically if the card was already issued, only update on swipe to refresh - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } }.launchIn(modelScope) @@ -556,9 +547,7 @@ internal class WalletModel @Inject constructor( wallets = action.wallets, clickIntents = clickIntents, walletImageResolver = walletImageResolver, - isMainScreenQrScanningEnabled = walletFeatureToggles.isMainScreenQrScanningEnabled, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) @@ -605,7 +594,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) } @@ -627,7 +615,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) } @@ -642,7 +629,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) @@ -704,7 +690,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) @@ -844,9 +829,9 @@ internal class WalletModel @Inject constructor( } } - private fun applyPendingTokenSyncs() { - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.applyPendingSyncs() + private fun applyPendingAssetsDiscovery() { + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.applyPendingAssetsDiscovery() } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt index efa8fe4574..5f01821a79 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt @@ -22,11 +22,10 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.pay.TangemPayEligibilityManager -import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig @@ -72,7 +71,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( private val produceInitialDataTangemPay: ProduceTangemPayInitialDataUseCase, private val getWalletMetainfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, - private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase, private val tangemPayOnboardingRepository: OnboardingRepository, private val tangemPayEligibilityManager: TangemPayEligibilityManager, private val uiMessageSender: UiMessageSender, @@ -85,7 +83,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) { return } - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } @@ -100,7 +97,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( modelScope.launch { produceInitialDataTangemPay.invoke(userWallet.walletId) .onRight { - tangemPayMainScreenCustomerInfoUseCase.fetch(userWallet.walletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWallet.walletId)) } .onLeft { @@ -277,7 +273,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( modelScope.launch { tangemPayOnboardingRepository.disableTangemPay(userWalletId) .onRight { - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } .onLeft { uiMessageSender.send(ToastMessage(resourceReference(R.string.common_something_went_wrong))) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 8f880da28c..71220bf8a9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -36,6 +36,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBot import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletCurrencyActionsConverter import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.collectLatest @@ -112,6 +113,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase, private val tokenListAnalyticsSender: TokenListAnalyticsSender, private val uiMessageSender: UiMessageSender, + private val walletFeatureToggles: WalletFeatureToggles, ) : BaseWalletClickIntents(), WalletContentClickIntents { override fun onDetailsClick() { @@ -119,7 +121,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } override fun onOrganizeTokensClick() { - router.openOrganizeTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) + val userWalletId = stateHolder.getSelectedWalletId() + if (walletFeatureToggles.isAddAndManageTokensEnabled) { + router.openAddAndManageBottomSheet(userWalletId = userWalletId) + } else { + router.openOrganizeTokensScreen(userWalletId = userWalletId) + } } override fun onDismissMarketsTooltip() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 58ea423a9a..9923f6ffa4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -17,13 +17,13 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap @@ -44,7 +44,6 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.staking.model.StakingOption -import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase @@ -455,13 +454,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( override fun onMultiWalletSwapClick(userWalletId: UserWalletId) { val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return - when (val tokenListState = selectedWallet.tokensListState) { - is WalletTokensListState.ContentState.Content -> checkSwapCryptoAvailability( - tokenCount = tokenListState.items.count { it is TokensListItemUM.Token }, - ) - is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability( - tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } }, - ) + when (selectedWallet.tokensListState) { + is WalletTokensListState.ContentState.Content, + is WalletTokensListState.ContentState.PortfolioContent, + -> Unit WalletTokensListState.ContentState.Loading, WalletTokensListState.ContentState.Locked, WalletTokensListState.Empty, @@ -470,7 +466,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch { val swapRoute = getSwapRoute( - AppRoute.SwapCrypto(userWalletId = userWalletId), + AppRoute.Swap( + userWalletId = userWalletId, + screenSource = AnalyticsParam.ScreensSources.Main.value, + ), ) onMultiWalletActionClick( statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId), @@ -660,7 +659,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrencyStatus.currency, + cryptoCurrency = cryptoCurrencyStatus.currency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.LongTap.value, ), @@ -675,11 +674,4 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } } - - private fun checkSwapCryptoAvailability(tokenCount: Int) { - if (tokenCount < 2) { - analyticsEventHandler.send(event = MainScreenAnalyticsEvent.ButtonSwap(AnalyticsParam.Status.Error)) - uiMessageSender.send(WalletAlertUM.insufficientTokensCountForSwapping()) - } - } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 665908c863..7c66a4acc4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -9,6 +9,7 @@ import com.tangem.common.ui.userwallet.handle import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic.ButtonSupport +import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.review.ReviewManager @@ -40,7 +41,7 @@ import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.Program import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.PromotionBannerClicked import com.tangem.domain.tokens.model.details.NavigationAction -import com.tangem.domain.tokensync.usecase.AcknowledgeTokenSyncCompletionUseCase +import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase import com.tangem.domain.wallets.usecase.* import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic @@ -97,9 +98,9 @@ internal interface WalletWarningsClickIntents { fun onCloseUpgradeBannerClick(userWalletId: UserWalletId) - fun onDismissTokenSyncNotification(userWalletId: UserWalletId) + fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId) - fun onTokenSyncManageClick(userWalletId: UserWalletId) + fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId) } @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @@ -132,7 +133,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val uiMessageSender: UiMessageSender, private val reviewManager: ReviewManager, private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase, - private val acknowledgeTokenSyncCompletionUseCase: AcknowledgeTokenSyncCompletionUseCase, + private val acknowledgeAssetsDiscoveryCompletionUseCase: AcknowledgeAssetsDiscoveryCompletionUseCase, ) : BaseWalletClickIntents(), WalletWarningsClickIntents { override fun onAddBackupCardClick() { @@ -508,12 +509,14 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( } } - override fun onDismissTokenSyncNotification(userWalletId: UserWalletId) { - acknowledgeTokenSyncCompletionUseCase(userWalletId) + override fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId) { + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.ButtonCloseBanner()) + acknowledgeAssetsDiscoveryCompletionUseCase(userWalletId) } - override fun onTokenSyncManageClick(userWalletId: UserWalletId) { - acknowledgeTokenSyncCompletionUseCase(userWalletId) + override fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId) { + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.ButtonManageTokens()) + acknowledgeAssetsDiscoveryCompletionUseCase(userWalletId) router.openManageTokensScreen( AccountId.forMainCryptoPortfolio(userWalletId), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt index d1ee8ec418..a15503fa38 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt @@ -9,9 +9,6 @@ internal class DefaultWalletFeatureToggles @Inject constructor( private val featureToggles: FeatureTogglesManager, ) : WalletFeatureToggles { - override val isWalletReorderFeatureEnabled: Boolean - get() = featureToggles.isFeatureEnabled(FeatureToggles.WALLET_REORDER_FEATURE_ENABLED) - - override val isMainScreenQrScanningEnabled: Boolean - get() = featureToggles.isFeatureEnabled(FeatureToggles.MAIN_SCREEN_QR_SCANNING_ENABLED) + override val isAddAndManageTokensEnabled: Boolean + get() = featureToggles.isFeatureEnabled(FeatureToggles.ADD_AND_MANAGE_TOKENS_ENABLED) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt index ec819b7869..bed6dd3ecd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt @@ -3,14 +3,14 @@ package com.tangem.feature.wallet.presentation.account import com.tangem.core.decompose.di.ModelScoped import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier -import com.tangem.domain.account.status.utils.ExpandedAccountsHolder import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.account.status.utils.MainExpandedAccountsHolder import javax.inject.Inject @ModelScoped internal class AccountDependencies @Inject constructor( val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - val expandedAccountsHolder: ExpandedAccountsHolder, + val expandedAccountsHolder: MainExpandedAccountsHolder, val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, val singleAccountStatusSupplier: SingleAccountStatusSupplier, ) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt index c52c371251..c8d1027107 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt @@ -91,6 +91,8 @@ internal object WalletScreenPreviewDataLegacy { ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + textRes = R.string.organize_tokens_title, + iconRes = R.drawable.ic_filter_24, isEnabled = true, onClick = {}, ), @@ -119,6 +121,8 @@ internal object WalletScreenPreviewDataLegacy { ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + textRes = R.string.organize_tokens_title, + iconRes = R.drawable.ic_filter_24, isEnabled = true, onClick = {}, ), @@ -149,6 +153,8 @@ internal object WalletScreenPreviewDataLegacy { ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + textRes = R.string.organize_tokens_title, + iconRes = R.drawable.ic_filter_24, isEnabled = true, onClick = {}, ), @@ -211,15 +217,8 @@ internal object WalletScreenPreviewDataLegacy { isFlickering = false, onItemClick = { }, ), - tangemPayState = TangemPayState.Card( - lastFourDigits = stringReference("*1234"), - balanceText = stringReference("$10"), - balanceSymbol = stringReference("USDC"), - onClick = {}, - ), type = WalletType.Cold, tangemPayMainUM = TangemPayMainUM.Empty, - isTangemPayRefactorEnabled = false, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt index 57e11b02aa..2bb705a19e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt @@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.styledStringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM internal object WalletBalancePreview { @@ -38,6 +39,36 @@ internal object WalletBalancePreview { isZeroBalance = false, ) + val syncProgress = WalletBalanceUM.Content( + id = UserWalletId("0"), + name = "My Wallet", + balanceInAppBar = combinedReference( + stringReference("1,234"), + styledStringReference( + ".56", + { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ), + stringReference(" $"), + ), + balance = combinedReference( + stringReference("1,234"), + styledStringReference( + ".56", + { + TangemTheme.typography2.headingRegular28.toSpanStyle() + }, + ), + stringReference(" $"), + ), + deviceIcon = DeviceIconUM.Stub(cardsCount = 3), + isBalanceFlickering = false, + isZeroBalance = false, + additionalInfo = WalletAdditionalInfo( + hideable = false, + content = WalletAdditionalInfo.Content.SyncProgress(37), + ), + ) + val hiddenBalanceContent = content.copy(balance = content.balance.orMaskWithStars(true)) val loading: WalletBalanceUM.Loading = WalletBalanceUM.Loading( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 1eadd2b07b..962bd6549c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -32,6 +32,7 @@ import javax.inject.Inject /** Default implementation of wallet feature router */ @ModelScoped +@Suppress("TooManyFunctions") internal class DefaultWalletRouter @Inject constructor( private val router: AppRouter, private val urlOpener: UrlOpener, @@ -66,14 +67,20 @@ internal class DefaultWalletRouter @Inject constructor( ) } - override fun openManageTokensScreen(accountId: AccountId) { + override fun openManageTokensScreen(accountId: AccountId, source: AppRoute.ManageTokens.Source) { val route = AppRoute.ManageTokens( - source = AppRoute.ManageTokens.Source.ACCOUNT, + source = source, accountId = accountId, ) router.push(route) } + override fun openAddAndManageBottomSheet(userWalletId: UserWalletId) { + dialogNavigation.activate( + configuration = WalletDialogConfig.AddAndManage(userWalletId = userWalletId), + ) + } + override fun openOnboardingScreen(scanResponse: ScanResponse, continueBackup: Boolean) { router.push( AppRoute.Onboarding( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 7d772e539c..597093c071 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.SharedFlow [REDACTED_AUTHOR] */ @Stable +@Suppress("TooManyFunctions") internal interface InnerWalletRouter { val dialogNavigation: SlotNavigation @@ -47,7 +48,13 @@ internal interface InnerWalletRouter { fun openDetailsScreen(selectedWalletId: UserWalletId) /** Open manage tokens screen */ - fun openManageTokensScreen(accountId: AccountId) + fun openManageTokensScreen( + accountId: AccountId, + source: AppRoute.ManageTokens.Source = AppRoute.ManageTokens.Source.ACCOUNT, + ) + + /** Open add and manage tokens bottom sheet */ + fun openAddAndManageBottomSheet(userWalletId: UserWalletId) /** Open onboarding screen */ fun openOnboardingScreen(scanResponse: ScanResponse, continueBackup: Boolean = false) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt index d4d08e2e70..0a6847ed30 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt @@ -150,7 +150,7 @@ internal class TokenListAnalyticsSender @Inject constructor( ) { // for now send only for Polkadot ecosystem blockchains // later dependency on Blockchain will be removed and use token name - when (val blockchain = Blockchain.fromNetworkId(currencyStatus.currency.network.backendId)) { + when (val blockchain = Blockchain.fromNetworkId(currencyStatus.currency.network.rawId)) { Blockchain.Polkadot, Blockchain.AlephZero, Blockchain.Kusama, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt index a54705233c..bd8b2197a6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt @@ -2,9 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.models.kyc.KycStatus -import com.tangem.domain.pay.model.MainScreenCustomerInfo -import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import javax.inject.Inject @@ -15,26 +13,24 @@ internal class WalletTangemPayAnalyticsEventSender @Inject constructor( private val screenLifecycleProvider: ScreenLifecycleProvider, ) { - private val sentEvents = mutableSetOf() + private val sentEvents = mutableSetOf() - fun send(customerInfo: MainScreenCustomerInfo) { + fun send(statusValue: PaymentAccountStatusValue) { if (screenLifecycleProvider.isBackgroundState.value) return - val cardInfo = customerInfo.info.cardInfo - val productInstance = customerInfo.info.productInstance - - // TODO: TangemPay refactor analytics - // when statement copied from TangemPayUpdateInfoStateTransformer. Be careful when editing - val event = when { - // ignore cancelled state on analytics - customerInfo.orderStatus == OrderStatus.CANCELED -> return - // ignore kyc not approved state on analytics - customerInfo.info.kycStatus != KycStatus.APPROVED -> return - cardInfo != null && productInstance != null -> return - else -> TangemPayAnalyticsEvents.IssuingBannerDisplayed() + val event = when (statusValue) { + is PaymentAccountStatusValue.IssuingCard -> TangemPayAnalyticsEvents.IssuingBannerDisplayed() + PaymentAccountStatusValue.Empty, + is PaymentAccountStatusValue.Error, + is PaymentAccountStatusValue.Loaded, + PaymentAccountStatusValue.Loading, + PaymentAccountStatusValue.NotCreated, + is PaymentAccountStatusValue.UnderReview, + is PaymentAccountStatusValue.Deactivated, + -> return } - if (sentEvents.add(event)) { + if (sentEvents.add(event.id)) { analyticsEventHandler.send(event) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt index 96f7c46639..4c57e85428 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt @@ -120,7 +120,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor( is WalletNotification.Warning.TangemPayRefreshNeeded -> null is WalletNotification.Warning.TangemPayUnreachable -> null is WalletNotification.UpgradeHotWalletPromo -> null - is WalletNotification.TokenSyncCompleted -> null + is WalletNotification.AssetsDiscoveryCompleted -> null is WalletNotification.CreateTangemPayAccount -> null } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index b56ff52261..c4b2be588a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -27,8 +27,8 @@ import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.settings.IsReadyToShowRateAppUseCase -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents @@ -61,7 +61,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private val shouldShowUpgradeHotWalletBannerUseCase: ShouldShowUpgradeHotWalletBannerUseCase, private val getUpgradeBannerClosureTimestampUseCase: GetUpgradeBannerClosureTimestampUseCase, private val checkHotWalletUpgradeBannerUseCase: CheckHotWalletUpgradeBannerUseCase, - private val observeTokenSyncUseCase: ObserveTokenSyncUseCase, + private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { @@ -71,11 +71,12 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( val params = SingleAccountStatusListProducer.Params(userWallet.walletId) val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params) - val tokenSyncProgressFlow = if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) { - observeTokenSyncUseCase(userWallet.walletId).distinctUntilChanged() - } else { - flowOf(TokenSyncProgress.Idle) - } + val assetsDiscoveryProgressFlow = + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && userWallet is UserWallet.Hot) { + observeAssetsDiscoveryUseCase(userWallet.walletId).distinctUntilChanged() + } else { + flowOf(AssetsDiscoveryProgress.Idle) + } return combine( accountStatusListFlow, @@ -92,7 +93,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( .distinctUntilChanged(), getUpgradeBannerClosureTimestampUseCase(userWallet.walletId) .distinctUntilChanged(), - tokenSyncProgressFlow, + assetsDiscoveryProgressFlow, ) { array -> array } .map { array -> val accountStatusList = array[0] as AccountStatusList @@ -104,7 +105,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( val shouldShowYieldPromo = array[6] as Boolean val shouldShowUpgradeBanner = array[7] as Boolean val closureTimestamp = array[8] as? Long - val tokenSyncProgress = array[9] as TokenSyncProgress + val assetsDiscoveryProgress = array[9] as AssetsDiscoveryProgress val flattenCurrencies = accountStatusList.flattenCurrencies() val paymentAccountStatus = accountStatusList.accountStatuses @@ -149,9 +150,9 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( clickIntents = clickIntents, ) - addTokenSyncCompletedNotification( + addAssetsDiscoveryCompletedNotification( userWallet = userWallet, - tokenSyncProgress = tokenSyncProgress, + assetsDiscoveryProgress = assetsDiscoveryProgress, clickIntents = clickIntents, ) @@ -208,7 +209,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( is PaymentAccountStatusValue.IssuingCard, is PaymentAccountStatusValue.Loaded, is PaymentAccountStatusValue.Loading, - is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, is PaymentAccountStatusValue.Empty, is PaymentAccountStatusValue.Deactivated, @@ -402,17 +402,17 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( // } // } - private fun MutableList.addTokenSyncCompletedNotification( + private fun MutableList.addAssetsDiscoveryCompletedNotification( userWallet: UserWallet, - tokenSyncProgress: TokenSyncProgress, + assetsDiscoveryProgress: AssetsDiscoveryProgress, clickIntents: WalletClickIntents, ) { addIf( - element = WalletNotification.TokenSyncCompleted( - onCloseClick = { clickIntents.onDismissTokenSyncNotification(userWallet.walletId) }, - onManageTokensClick = { clickIntents.onTokenSyncManageClick(userWallet.walletId) }, + element = WalletNotification.AssetsDiscoveryCompleted( + onCloseClick = { clickIntents.onDismissAssetsDiscoveryNotification(userWallet.walletId) }, + onManageTokensClick = { clickIntents.onAssetsDiscoveryManageClick(userWallet.walletId) }, ), - condition = tokenSyncProgress is TokenSyncProgress.Completed, + condition = assetsDiscoveryProgress is AssetsDiscoveryProgress.Completed, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 931e87bff3..a33dcd20ed 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -237,7 +237,6 @@ internal class GetWalletNotificationsFactory @Inject constructor( is PaymentAccountStatusValue.IssuingCard, is PaymentAccountStatusValue.Loaded, is PaymentAccountStatusValue.Loading, - is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, is PaymentAccountStatusValue.Empty, is PaymentAccountStatusValue.Deactivated, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt index 838ea96770..b131f971d8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt @@ -10,7 +10,7 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.getCardsCount import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import java.math.BigDecimal @@ -32,7 +32,7 @@ internal object WalletAdditionalInfoFactory { fun resolve( wallet: UserWallet, currencyAmount: BigDecimal? = null, - syncProgress: TokenSyncProgressUM = TokenSyncProgressUM.Idle, + syncProgress: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle, ): WalletAdditionalInfo { return when (wallet) { is UserWallet.Cold -> { @@ -46,8 +46,8 @@ internal object WalletAdditionalInfoFactory { } } - private fun UserWallet.Hot.resolveAdditionalInfo(syncProgress: TokenSyncProgressUM): WalletAdditionalInfo { - val content = if (syncProgress is TokenSyncProgressUM.InProgress) { + private fun UserWallet.Hot.resolveAdditionalInfo(syncProgress: AssetsDiscoveryProgressUM): WalletAdditionalInfo { + val content = if (syncProgress is AssetsDiscoveryProgressUM.InProgress) { WalletAdditionalInfo.Content.SyncProgress(syncProgress.progressPercent) } else { WalletAdditionalInfo.Content.Text( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt index b4cae53df1..c6b203e64d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt @@ -2,16 +2,15 @@ package com.tangem.feature.wallet.presentation.wallet.domain import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.wallet.WalletBalanceFetcher -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveInAndJoin +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import com.tangem.utils.logging.TangemLogger import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -28,7 +27,6 @@ import javax.inject.Singleton internal class WalletContentFetcher @Inject constructor( private val walletBalanceFetcher: WalletBalanceFetcher, private val dispatchers: CoroutineDispatcherProvider, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, ) { private val fetchingJobMap = ConcurrentHashMap() @@ -66,12 +64,8 @@ internal class WalletContentFetcher @Inject constructor( TangemLogger.d("Start fetching for $userWalletId") val maybeResult = launch { - walletBalanceFetcher( - params = WalletBalanceFetcher.Params( - userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, - ), - ).onLeft { TangemLogger.e("Error", it) } + walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWalletId)) + .onLeft { TangemLogger.e("Error", it) } } .saveInAndJoin(jobHolder) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt index 045a9c47bf..b6ea0fcf2a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt @@ -18,7 +18,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor( private val walletNotificationsSubscriberFactory: WalletNotificationsSubscriber.Factory, private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory, private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory, - private val tokenSyncSubscriberFactory: TokenSyncSubscriber.Factory, + private val assetsDiscoverySubscriberFactory: AssetsDiscoverySubscriber.Factory, private val tokenListAnalyticsSubscriberFactory: TokenListAnalyticsSubscriber.Factory, private val designFeatureToggles: DesignFeatureToggles, private val hotWalletFeatureToggles: HotWalletFeatureToggles, @@ -36,8 +36,8 @@ internal class MultiWalletContentLoader @AssistedInject constructor( multiWalletActionButtonsSubscriberFactory.create(userWallet), tangemPayMainSubscriberFactory.create(userWallet), tokenListAnalyticsSubscriberFactory.create(userWallet), - if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) { - tokenSyncSubscriberFactory.create(userWallet) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && userWallet is UserWallet.Hot) { + assetsDiscoverySubscriberFactory.create(userWallet) } else { null }, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/AssetsDiscoveryProgressUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/AssetsDiscoveryProgressUM.kt new file mode 100644 index 0000000000..e66ccd7258 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/AssetsDiscoveryProgressUM.kt @@ -0,0 +1,13 @@ +package com.tangem.feature.wallet.presentation.wallet.state.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed class AssetsDiscoveryProgressUM { + + data object Idle : AssetsDiscoveryProgressUM() + + data class InProgress(val progressPercent: Int) : AssetsDiscoveryProgressUM() + + data object Completed : AssetsDiscoveryProgressUM() +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TangemPayState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TangemPayState.kt deleted file mode 100644 index 4eae554a24..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TangemPayState.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.model - -import androidx.annotation.DrawableRes -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.extensions.TextReference - -@Immutable -internal sealed class TangemPayState { - - object Empty : TangemPayState() - - data object Loading : TangemPayState() - - data class OnboardingBanner( - val onClick: () -> Unit, - val closeOnClick: () -> Unit, - ) : TangemPayState() - - data class Progress( - val title: TextReference, - val description: TextReference, - val buttonText: TextReference, - @DrawableRes val iconRes: Int, - val onButtonClick: () -> Unit, - val showProgress: Boolean = false, - ) : TangemPayState() - - data class FailedIssue( - val title: TextReference, - val description: TextReference, - @DrawableRes val iconRes: Int, - val onButtonClick: () -> Unit, - ) : TangemPayState() - - data class Card( - val lastFourDigits: TextReference, - val balanceText: TextReference, - val balanceSymbol: TextReference, - val onClick: () -> Unit, - ) : TangemPayState() - - data class RefreshNeeded( - val notification: WalletNotification, - ) : TangemPayState() - - data class TemporaryUnavailable(val notification: WalletNotification) : TangemPayState() - - data object ExposedDevice : TangemPayState() -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TokenSyncProgressUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TokenSyncProgressUM.kt deleted file mode 100644 index 2bcd1e9487..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TokenSyncProgressUM.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.model - -import androidx.compose.runtime.Immutable - -@Immutable -internal sealed class TokenSyncProgressUM { - - data object Idle : TokenSyncProgressUM() - - data class InProgress(val progressPercent: Int) : TokenSyncProgressUM() - - data object Completed : TokenSyncProgressUM() -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt index cc322c0a38..428a23ab2f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt @@ -28,6 +28,9 @@ internal sealed interface WalletBalanceUM { /** Wallet Icon */ val deviceIcon: DeviceIconUM + /** Wallet additional info (e.g. card count, sync progress) */ + val additionalInfo: WalletAdditionalInfo? + /** * Wallet card content state * @@ -39,6 +42,7 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, val balance: TextReference, val balanceInAppBar: TextReference, val isBalanceFlickering: Boolean, @@ -55,6 +59,7 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, ) : WalletBalanceUM /** @@ -67,6 +72,7 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, ) : WalletBalanceUM /** @@ -79,14 +85,18 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, ) : WalletBalanceUM - fun copySealed(name: String): WalletBalanceUM { + fun copySealed( + name: String = this.name, + additionalInfo: WalletAdditionalInfo? = this.additionalInfo, + ): WalletBalanceUM { return when (this) { - is Content -> copy(name = name) - is Error -> copy(name = name) - is Loading -> copy(name = name) - is Empty -> copy(name = name) + is Content -> copy(name = name, additionalInfo = additionalInfo) + is Error -> copy(name = name, additionalInfo = additionalInfo) + is Loading -> copy(name = name, additionalInfo = additionalInfo) + is Empty -> copy(name = name, additionalInfo = additionalInfo) } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index 8322d1c8df..52cb3834c7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -50,6 +50,9 @@ internal sealed interface WalletDialogConfig { @Serializable data class KycRejected(val walletId: UserWalletId, val customerId: String) : WalletDialogConfig + @Serializable + data class AddAndManage(val userWalletId: UserWalletId) : WalletDialogConfig + @Serializable data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index c960e5ba67..ecdd0aa9e5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -435,7 +435,7 @@ sealed class WalletNotification(val config: NotificationConfig) { ), ) - data class TokenSyncCompleted( + data class AssetsDiscoveryCompleted( val onCloseClick: () -> Unit, val onManageTokensClick: () -> Unit, ) : WalletNotification( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt index 3903da422a..6ee912f8fc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt @@ -24,10 +24,8 @@ internal sealed interface WalletState : WalletStateHolder { abstract val tokensListState: WalletTokensListState abstract val nftState: WalletNFTItemUM abstract val type: WalletType - abstract val tangemPayState: TangemPayState abstract val tangemPayMainUM: TangemPayMainUM - abstract val isTangemPayRefactorEnabled: Boolean // TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED - abstract val tokenSyncProgressUM: TokenSyncProgressUM + abstract val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM data class Content( override val pullToRefreshConfig: PullToRefreshConfig, @@ -38,10 +36,8 @@ internal sealed interface WalletState : WalletStateHolder { override val tokensListState: WalletTokensListState, override val nftState: WalletNFTItemUM, override val type: WalletType, - override val tangemPayState: TangemPayState, override val tangemPayMainUM: TangemPayMainUM, - override val isTangemPayRefactorEnabled: Boolean, - override val tokenSyncProgressUM: TokenSyncProgressUM = TokenSyncProgressUM.Idle, + override val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle, ) : MultiCurrency() data class Locked( @@ -60,10 +56,8 @@ internal sealed interface WalletState : WalletStateHolder { override val tokensListState = WalletTokensListState.ContentState.Locked override val nftState: WalletNFTItemUM = WalletNFTItemUM.Hidden - override val tangemPayState: TangemPayState = TangemPayState.Empty override val tangemPayMainUM: TangemPayMainUM = TangemPayMainUM.Empty - override val isTangemPayRefactorEnabled: Boolean = false - override val tokenSyncProgressUM: TokenSyncProgressUM = TokenSyncProgressUM.Idle + override val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt index 82050bd5fb..69cb21f25d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt @@ -49,5 +49,10 @@ internal sealed class WalletTokensListState { } } - data class OrganizeTokensButtonConfig(val isEnabled: Boolean, val onClick: () -> Unit) + data class OrganizeTokensButtonConfig( + val textRes: Int, + val iconRes: Int, + val isEnabled: Boolean, + val onClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt index 7cba602ffb..ec3d6943be 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt @@ -13,7 +13,6 @@ internal class AddWalletTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -21,7 +20,6 @@ internal class AddWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index 4c669daeed..a9774d2e2d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt @@ -27,8 +27,6 @@ internal class InitializeWalletsTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isMainScreenQrScanningEnabled: Boolean = false, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -36,7 +34,6 @@ internal class InitializeWalletsTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } @@ -65,15 +62,11 @@ internal class InitializeWalletsTransformer( private fun createTopBarConfig(): WalletTopBarConfig { return WalletTopBarConfig( - endActions = listOfNotNull( - if (isMainScreenQrScanningEnabled) { - TangemTopBarActionUM( - iconRes = CoreUiR.drawable.ic_qrcode_scaner_24, - onClick = clickIntents::onScanQrClick, - ) - } else { - null - }, + endActions = listOf( + TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_qrcode_scaner_24, + onClick = clickIntents::onScanQrClick, + ), TangemTopBarActionUM( iconRes = CoreUiR.drawable.ic_more_default_24, onClick = clickIntents::onDetailsClick, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt index ed1b3a1b10..0c1b198d0c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt @@ -24,7 +24,6 @@ internal class ReinitializeNewWalletTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -32,7 +31,6 @@ internal class ReinitializeNewWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt index 00a8977b27..6dab085776 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt @@ -20,7 +20,6 @@ internal class ReinitializeWalletTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletStateTransformer(userWalletId = userWallet.walletId) { private val walletLoadingStateFactory by lazy { @@ -28,7 +27,6 @@ internal class ReinitializeWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenSyncProgressTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt similarity index 68% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenSyncProgressTransformer.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt index 0dfbaf6dd9..a0adf2a7e4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenSyncProgressTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt @@ -2,27 +2,35 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM -internal class SetTokenSyncProgressTransformer( +internal class SetAssetsDiscoveryProgressTransformer( private val userWallet: UserWallet, - private val progress: TokenSyncProgressUM, + private val progress: AssetsDiscoveryProgressUM, ) : WalletStateTransformer(userWallet.walletId) { override fun transform(prevState: WalletState): WalletState { return when (prevState) { is WalletState.MultiCurrency.Content -> prevState.copy( walletCardState = updateCardState(prevState.walletCardState), - tokenSyncProgressUM = progress, + assetsDiscoveryProgressUM = progress, ) else -> prevState } } - override fun transform(walletUM: WalletUM): WalletUM = walletUM + override fun transform(walletUM: WalletUM): WalletUM { + val additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet, syncProgress = progress) + return when (walletUM) { + is WalletUM.Content -> walletUM.copy( + walletsBalanceUM = walletUM.walletsBalanceUM.copySealed(additionalInfo = additionalInfo), + ) + is WalletUM.Locked -> walletUM + } + } private fun updateCardState(cardState: WalletCardState): WalletCardState { val additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet, syncProgress = progress) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt index ef5698e378..e8accceb0b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt @@ -61,7 +61,7 @@ internal class SetTokenListErrorTransformer( walletsBalanceUM = walletUM.walletsBalanceUM.toLoadedState(), tokensListUM = WalletTokensListUM.Empty( onEmptyClick = { - clickIntents.onTokenSyncManageClick(walletUM.walletsBalanceUM.id) + clickIntents.onAssetsDiscoveryManageClick(walletUM.walletsBalanceUM.id) }, ), buttons = walletUM.disableButtons(), @@ -98,6 +98,7 @@ internal class SetTokenListErrorTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, balanceInAppBar = BigDecimal.ZERO.formatStyled { fiat( fiatCurrencyCode = appCurrency.code, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt index 0557523188..524890d9ec 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt @@ -25,6 +25,7 @@ internal class SetTokenListTransformer( private val shouldShowMainPromo: Boolean, private val isAccountsModeEnabled: Boolean, private val isRedesignEnabled: Boolean, + private val isAddAndManageTokensEnabled: Boolean, ) : WalletStateTransformer(userWallet.walletId) { private val tangemPayConverter by lazy { @@ -105,6 +106,7 @@ internal class SetTokenListTransformer( yieldModuleApyMap = yieldSupplyApyMap, stakingAvailabilityMap = stakingAvailabilityMap, shouldShowMainPromo = shouldShowMainPromo, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ).convert(value = this) } @@ -123,7 +125,7 @@ internal class SetTokenListTransformer( if (params !is TokenConverterParams.Account) { return WalletTokensListUM.Empty( onEmptyClick = { - clickIntents.onTokenSyncManageClick(userWallet.walletId) + clickIntents.onAssetsDiscoveryManageClick(userWallet.walletId) }, ) } @@ -137,6 +139,7 @@ internal class SetTokenListTransformer( shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = isAccountsModeEnabled, expandedAccounts = params.expandedAccounts, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ).convert(value = params.accountList) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayExposedDeviceTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayExposedDeviceTransformer.kt deleted file mode 100644 index cd344f9f4f..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayExposedDeviceTransformer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayExposedDeviceTransformer( - userWalletId: UserWalletId, -) : WalletStateTransformer(userWalletId) { - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.ExposedDevice) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHiddenStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHiddenStateTransformer.kt deleted file mode 100644 index bbb5035c07..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHiddenStateTransformer.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayHiddenStateTransformer( - userWalletId: UserWalletId, -) : WalletStateTransformer(userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.Empty) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt index 9997dc0bcb..a25a960e0b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt @@ -1,7 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import com.tangem.features.tangempay.entity.TangemPayMainUM @@ -12,7 +11,7 @@ internal class TangemPayHideOnboardingStateTransformer( override fun transform(prevState: WalletState): WalletState { return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.Empty, tangemPayMainUM = TangemPayMainUM.Empty) + prevState.copy(tangemPayMainUM = TangemPayMainUM.Empty) } else { prevState } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayLoadingStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayLoadingStateTransformer.kt deleted file mode 100644 index 6404796e7d..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayLoadingStateTransformer.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayLoadingStateTransformer(userWalletId: UserWalletId) : WalletStateTransformer(userWalletId) { - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.Loading) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayOnboardingBannerStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayOnboardingBannerStateTransformer.kt deleted file mode 100644 index 4659e5f486..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayOnboardingBannerStateTransformer.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayOnboardingBannerStateTransformer( - userWalletId: UserWalletId, - private val onClick: (UserWalletId) -> Unit, - private val closeOnClick: (UserWalletId) -> Unit, -) : WalletStateTransformer(userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy( - tangemPayState = TangemPayState.OnboardingBanner( - onClick = { onClick(userWalletId) }, - closeOnClick = { closeOnClick(userWalletId) }, - ), - ) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshNeededStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshNeededStateTransformer.kt deleted file mode 100644 index b8673b8774..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshNeededStateTransformer.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayRefreshNeededStateTransformer( - userWalletId: UserWalletId, - private val userWallet: UserWallet, - private val onRefreshClick: () -> Unit, -) : WalletStateTransformer(userWalletId = userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - val tangemPayState = TangemPayState.RefreshNeeded( - notification = TangemPayRefreshNeeded( - buttonText = when (userWallet) { - is UserWallet.Cold -> resourceReference(id = R.string.home_button_scan) - is UserWallet.Hot -> resourceReference(id = R.string.tangempay_sync_needed_restore_access) - }, - onRefreshClick = onRefreshClick, - shouldShowProgress = false, - ), - ) - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = tangemPayState) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt index 17ff78b37f..77158bdb99 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt @@ -1,7 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM @@ -14,9 +13,6 @@ internal class TangemPayRefreshShowProgressTransformer( override fun transform(prevState: WalletState): WalletState { val multiContentState = prevState as? WalletState.MultiCurrency.Content ?: return prevState - val refreshNeededState = multiContentState.tangemPayState as? TangemPayState.RefreshNeeded ?: return prevState - val refreshNotification = - refreshNeededState.notification as? WalletNotification.Warning.TangemPayRefreshNeeded ?: return prevState val newWarnings = prevState.warnings.map { warning -> if (warning is WalletNotification.Warning.TangemPayRefreshNeeded) { warning.copy(shouldShowProgress = shouldShowProgress) @@ -25,12 +21,7 @@ internal class TangemPayRefreshShowProgressTransformer( } } - return multiContentState.copy( - tangemPayState = refreshNeededState.copy( - notification = refreshNotification.copy(shouldShowProgress = shouldShowProgress), - ), - warnings = newWarnings.toImmutableList(), - ) + return multiContentState.copy(warnings = newWarnings.toImmutableList()) } override fun transform(walletUM: WalletUM): WalletUM { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUnavailableStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUnavailableStateTransformer.kt deleted file mode 100644 index 5b2a7765a9..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUnavailableStateTransformer.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayUnavailableStateTransformer( - userWalletId: UserWalletId, -) : WalletStateTransformer(userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy( - tangemPayState = TangemPayState.TemporaryUnavailable( - notification = WalletNotification.Warning.TangemPayUnreachable, - ), - ) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt deleted file mode 100644 index 91e7b3a263..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.common.ui.R -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.models.kyc.KycStatus -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig -import com.tangem.domain.pay.model.CustomerInfo.CardInfo -import com.tangem.domain.pay.model.CustomerInfo.ProductInstance -import com.tangem.domain.pay.model.MainScreenCustomerInfo -import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.feature.wallet.child.wallet.model.intents.TangemPayIntents -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM -import java.util.Currency - -/** - * Hardcode Polygon chain id only for F&F. - * Later chain id will be fetched from BFF. - */ -private const val POLYGON_CHAIN_ID = 137 - -internal class TangemPayUpdateInfoStateTransformer( - userWalletId: UserWalletId, - private val value: MainScreenCustomerInfo, - private val cardFrozenState: TangemPayCardFrozenState, - private val tangemPayClickIntents: TangemPayIntents, -) : WalletStateTransformer(userWalletId = userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - val tangemPayState = createInitialState() - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = tangemPayState) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } - - private fun createInitialState(): TangemPayState { - val cardInfo = value.info.cardInfo - val productInstance = value.info.productInstance - val customerId = value.info.customerId ?: "Unknown" - - // when statement copied to WalletTangemPayAnalyticsEventSender. Be careful when editing. - return when { - value.info.kycStatus != KycStatus.APPROVED && !value.info.customerId.isNullOrEmpty() -> - createKycInProgressState(kycStatus = value.info.kycStatus, customerId = customerId) - value.orderStatus == OrderStatus.CANCELED -> createCancelledState(customerId) - cardInfo != null && productInstance != null && value.orderStatus == OrderStatus.COMPLETED -> - getCardInfoState(customerId, cardInfo, productInstance) - else -> createIssueProgressState() - } - } - - private fun getCardInfoState( - customerId: String, - cardInfo: CardInfo, - productInstance: ProductInstance, - ): TangemPayState = TangemPayState.Card( - lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"), - balanceText = TextReference.Str(getBalanceText(cardInfo)), - balanceSymbol = stringReference("USDC"), // TODO hardcode for now - onClick = { - tangemPayClickIntents.openDetails( - userWalletId, - TangemPayDetailsConfig( - customerId = customerId, - cardId = productInstance.cardId, - isPinSet = cardInfo.isPinSet, - cardFrozenState = cardFrozenState, - cardNumberEnd = cardInfo.lastFourDigits, - chainId = POLYGON_CHAIN_ID, - isTangemPayDeactivated = false, - ), - ) - }, - ) - - private fun getBalanceText(cardInfo: CardInfo): String { - val currency = Currency.getInstance(cardInfo.currencyCode) - return cardInfo.balance.format { - fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol) - } - } - - private fun createKycInProgressState(kycStatus: KycStatus, customerId: String): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = when (kycStatus) { - KycStatus.REJECTED -> TextReference.Res(R.string.tangempay_kyc_has_failed) - else -> TextReference.Res(R.string.tangempay_kyc_in_progress) - }, - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = { - when (kycStatus) { - KycStatus.REJECTED -> tangemPayClickIntents.onKycRejectedClicked( - userWalletId = userWalletId, - customerId = customerId, - ) - else -> tangemPayClickIntents.onKycProgressClicked(userWalletId) - } - }, - ) - - private fun createIssueProgressState(): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_issuing_your_card), - buttonText = TextReference.EMPTY, - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = tangemPayClickIntents::onIssuingCardClicked, - showProgress = true, - ) - - private fun createCancelledState(customerId: String): TangemPayState = TangemPayState.FailedIssue( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_failed_to_issue_card), - iconRes = R.drawable.ic_alert_24, - onButtonClick = { tangemPayClickIntents.onIssuingFailedClicked(customerId) }, - ) -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt index 6e44868ca7..d80feaec8e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt @@ -9,16 +9,15 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenSta import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletLoadingStateFactory +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList -import com.tangem.utils.logging.TangemLogger internal class UnlockWalletTransformer( private val unlockedWallets: List, private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -26,7 +25,6 @@ internal class UnlockWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt index 86cc55373e..19d722f039 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt @@ -4,7 +4,7 @@ import com.tangem.domain.card.common.util.getCardsCount import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM @@ -19,7 +19,7 @@ internal class UpdateWalletCardsCountTransformer( return when (prevState) { is WalletState.MultiCurrency.Content -> { prevState.copy( - walletCardState = prevState.walletCardState.toUpdatedState(prevState.tokenSyncProgressUM), + walletCardState = prevState.walletCardState.toUpdatedState(prevState.assetsDiscoveryProgressUM), ) } is WalletState.SingleCurrency.Content -> { @@ -39,7 +39,7 @@ internal class UpdateWalletCardsCountTransformer( } private fun WalletCardState.toUpdatedState( - syncProgress: TokenSyncProgressUM = TokenSyncProgressUM.Idle, + syncProgress: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle, ): WalletCardState { return when (this) { is WalletCardState.Content -> copy( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt index d3a37dbb31..9765ff1052 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt @@ -29,6 +29,7 @@ internal class MultiWalletBalanceUMTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, ) } @@ -37,6 +38,7 @@ internal class MultiWalletBalanceUMTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, ) } @@ -45,6 +47,7 @@ internal class MultiWalletBalanceUMTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, balanceInAppBar = fiatBalance.amount.formatStyled { fiat( fiatCurrencyCode = appCurrency.code, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt index 3df7db2bb6..597e1b1837 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt @@ -1,17 +1,20 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter import com.tangem.common.ui.expressStatus.state.* +import com.tangem.common.ui.expressStatus.toActiveStatusText +import com.tangem.common.ui.expressStatus.toIconState import com.tangem.common.ui.notifications.ExpressNotificationsUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.appcurrency.model.AppCurrency @@ -36,6 +39,7 @@ internal class SingleWalletOnrampTransactionConverter( private val currency = cryptoCurrencyStatus.currency private val status = cryptoCurrencyStatus.value + @Suppress("LongMethod") override fun convert(value: OnrampTransaction): ExpressTransactionStateUM.OnrampUM { return ExpressTransactionStateUM.OnrampUM( info = ExpressTransactionStateInfoUM( @@ -56,6 +60,8 @@ internal class SingleWalletOnrampTransactionConverter( value.timestamp.toTimeFormat(), ), ), + timestampAgoFormatted = mapFormattedDate(value.timestamp), + activeStatus = value.status.toActiveStatusText(currency.name), toAmount = stringReference(value.toAmount.format { crypto(currency) }), toFiatAmount = stringReference( status.fiatRate?.multiply(value.toAmount).format { @@ -81,7 +87,7 @@ internal class SingleWalletOnrampTransactionConverter( url = value.fromCurrency.image, fallbackResId = R.drawable.ic_currency_24, ), - iconState = getIconState(value.status), + iconState = value.status.toIconState(), onGoToProviderClick = { url -> analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider()) clickIntents.onGoToProviderClick(url) @@ -134,18 +140,6 @@ internal class SingleWalletOnrampTransactionConverter( null } - private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM { - return when (status) { - OnrampStatus.Status.Verifying, - OnrampStatus.Status.RefundInProgress, - -> ExpressTransactionStateIconUM.Warning - OnrampStatus.Status.Refunded, - OnrampStatus.Status.Failed, - -> ExpressTransactionStateIconUM.Error - else -> ExpressTransactionStateIconUM.None - } - } - private fun convertStatuses(status: OnrampStatus.Status, externalTxUrl: String?): ExpressStatusUM { val statuses = with(status) { persistentListOf( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index dcad0972b5..9a7844cdeb 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -76,59 +76,44 @@ internal class TangemPayMainBlockConverter( cardFrozenState = TangemPayCardFrozenState.Unfrozen, cardNumberEnd = "", chainId = POLYGON_CHAIN_ID, + displayName = null, isTangemPayDeactivated = true, ), ) }, ) - is PaymentAccountStatusValue.Locked -> TangemPayMainUM.Content( - subtitle = stringReference("*${statusValue.lastFourDigits}"), - isBalanceFlickering = statusValue.source == StatusSource.CACHE, - balance = getBalanceText( - currencyCode = statusValue.currencyCode, - balance = statusValue.fiatBalance.availableBalance, - ), - balanceSubtitle = stringReference("USDC"), // TODO hardcode for now - shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, - onClick = { - tangemPayClickIntents.openDetails( - value.account.userWalletId, - TangemPayDetailsConfig( - customerId = statusValue.customerId, - cardId = statusValue.cardId, - isPinSet = statusValue.isPinSet, - cardFrozenState = TangemPayCardFrozenState.Frozen, - cardNumberEnd = statusValue.lastFourDigits, - chainId = POLYGON_CHAIN_ID, - isTangemPayDeactivated = false, - ), - ) - }, - ) - is PaymentAccountStatusValue.Loaded -> TangemPayMainUM.Content( - subtitle = stringReference("*${statusValue.lastFourDigits}"), - isBalanceFlickering = statusValue.source == StatusSource.CACHE, - balance = getBalanceText( - currencyCode = statusValue.currencyCode, - balance = statusValue.fiatBalance.availableBalance, - ), - balanceSubtitle = stringReference("USDC"), // TODO hardcode for now - shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, - onClick = { - tangemPayClickIntents.openDetails( - value.account.userWalletId, - TangemPayDetailsConfig( - customerId = statusValue.customerId, - cardId = statusValue.cardId, - isPinSet = statusValue.isPinSet, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - cardNumberEnd = statusValue.lastFourDigits, - chainId = POLYGON_CHAIN_ID, - isTangemPayDeactivated = false, - ), - ) - }, - ) + is PaymentAccountStatusValue.Loaded -> { + val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable + TangemPayMainUM.Content( + subtitle = stringReference("*${card.lastDigits}"), + isBalanceFlickering = statusValue.source == StatusSource.CACHE, + balance = getBalanceText( + currencyCode = statusValue.currencyCode, + balance = statusValue.fiatBalance.availableBalance, + ), + balanceSubtitle = stringReference("USDC"), // TODO hardcode for now + shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, + onClick = { + tangemPayClickIntents.openDetails( + value.account.userWalletId, + TangemPayDetailsConfig( + customerId = statusValue.customerId, + cardId = card.id, + isPinSet = card.hasPinCode, + cardFrozenState = if (card.isFrozen) { + TangemPayCardFrozenState.Frozen + } else { + TangemPayCardFrozenState.Unfrozen + }, + cardNumberEnd = card.lastDigits, + chainId = POLYGON_CHAIN_ID, + displayName = card.displayName, + isTangemPayDeactivated = false, + ), + ) + }, + ) + } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 926fdead7a..8d23a44a9c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -41,6 +41,7 @@ internal class TokenListStateConverter( private val yieldModuleApyMap: Map, private val stakingAvailabilityMap: Map, private val shouldShowMainPromo: Boolean, + private val isAddAndManageTokensEnabled: Boolean, ) : Converter { private val yieldSupplyPromoBannerConverter = YieldSupplyPromoBannerConverter( @@ -157,6 +158,8 @@ internal class TokenListStateConverter( } return if (currenciesSize > 1 && !isSingleCurrencyWalletWithToken()) { WalletOrganizeTokensButtonConfig( + textRes = organizeButtonTextRes(), + iconRes = organizeButtonIconRes(), isEnabled = tokenList.totalFiatBalance !is TotalFiatBalance.Loading, onClick = clickIntents::onOrganizeTokensClick, ) @@ -168,6 +171,8 @@ internal class TokenListStateConverter( private fun getOrganizeTokensButtonStateV2(accountList: AccountStatusList): WalletOrganizeTokensButtonConfig? { return if (accountList.flattenCurrencies().size > 1 && !isSingleCurrencyWalletWithToken()) { WalletOrganizeTokensButtonConfig( + textRes = organizeButtonTextRes(), + iconRes = organizeButtonIconRes(), isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading, onClick = clickIntents::onOrganizeTokensClick, ) @@ -176,6 +181,18 @@ internal class TokenListStateConverter( } } + private fun organizeButtonTextRes(): Int = if (isAddAndManageTokensEnabled) { + R.string.main_add_and_manage_tokens + } else { + R.string.organize_tokens_title + } + + private fun organizeButtonIconRes(): Int = if (isAddAndManageTokensEnabled) { + R.drawable.ic_filter_default_24 + } else { + R.drawable.ic_filter_24 + } + private fun isSingleCurrencyWalletWithToken(): Boolean { return selectedWallet is UserWallet.Cold && selectedWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt index 77c5ee9b32..06c31c47b7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt @@ -3,7 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import androidx.compose.ui.text.SpanStyle import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.badge.* diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt index c252326e14..a5535584a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt @@ -38,6 +38,7 @@ internal class WalletTokensListUMConverter( private val isAccountsModeEnabled: Boolean, private val expandedAccounts: Set, private val stakingAvailabilityMap: Map, + private val isAddAndManageTokensEnabled: Boolean, shouldShowMainPromo: Boolean, ) : Converter { @@ -164,15 +165,25 @@ internal class WalletTokensListUMConverter( } private fun getOrganizeButtonUM(accountList: AccountStatusList): TangemButtonUM? { + val textRes = if (isAddAndManageTokensEnabled) { + R.string.main_add_and_manage_tokens + } else { + R.string.organize_tokens_title + } + val iconRes = if (isAddAndManageTokensEnabled) { + R.drawable.ic_filter_default_24 + } else { + R.drawable.ic_filter_24 + } return if (accountList.flattenCurrencies().size > 1 && !selectedWallet.isSingleWalletWithToken()) { TangemButtonUM( - text = resourceReference(R.string.organize_tokens_title), + text = resourceReference(textRes), isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading, size = TangemButtonSize.X9, shape = TangemButtonShape.Rounded, type = TangemButtonType.PrimaryInverse, tangemIconUM = TangemIconUM.Icon( - iconRes = R.drawable.ic_filter_default_24, + iconRes = iconRes, tintReference = { if (accountList.totalFiatBalance !is TotalFiatBalance.Loading) { TangemTheme.colors2.graphic.neutral.primary diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt index 1b1be5d304..1140b80dd0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt @@ -34,7 +34,6 @@ internal class WalletLoadingStateFactory( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) { fun create(userWallet: UserWallet): WalletState { @@ -83,9 +82,7 @@ internal class WalletLoadingStateFactory( tokensListState = WalletTokensListState.ContentState.Loading, nftState = WalletNFTItemUM.Hidden, type = WalletType.Hot, - tangemPayState = TangemPayState.Empty, tangemPayMainUM = TangemPayMainUM.Empty, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } @@ -99,9 +96,7 @@ internal class WalletLoadingStateFactory( tokensListState = WalletTokensListState.ContentState.Loading, nftState = WalletNFTItemUM.Hidden, type = WalletType.Cold, - tangemPayState = TangemPayState.Empty, tangemPayMainUM = TangemPayMainUM.Empty, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt index 2d7ba38899..a440c34a7f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt @@ -12,6 +12,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoU import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.coroutines.combine7 import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -39,8 +40,12 @@ internal class AccountListSubscriber @AssistedInject constructor( private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase, private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, private val designFeatureToggles: DesignFeatureToggles, + private val walletFeatureToggles: WalletFeatureToggles, ) : BasicAccountListSubscriber() { + override val isAddAndManageTokensEnabled: Boolean + get() = walletFeatureToggles.isAddAndManageTokensEnabled + override fun create(coroutineScope: CoroutineScope): Flow<*> = combine7( flow1 = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenSyncSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AssetsDiscoverySubscriber.kt similarity index 56% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenSyncSubscriber.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AssetsDiscoverySubscriber.kt index 1095805674..ba021b62af 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenSyncSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AssetsDiscoverySubscriber.kt @@ -1,11 +1,11 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM -import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTokenSyncProgressTransformer +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetAssetsDiscoveryProgressTransformer import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -13,25 +13,25 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onEach -internal class TokenSyncSubscriber @AssistedInject constructor( +internal class AssetsDiscoverySubscriber @AssistedInject constructor( @Assisted private val userWallet: UserWallet, private val stateController: WalletStateController, - private val observeTokenSyncUseCase: ObserveTokenSyncUseCase, + private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase, ) : WalletSubscriber() { override fun create(coroutineScope: CoroutineScope): Flow<*> { - return observeTokenSyncUseCase(userWallet.walletId) + return observeAssetsDiscoveryUseCase(userWallet.walletId) .onEach { current -> handleProgress(userWallet, current) } } - private fun handleProgress(userWallet: UserWallet, current: TokenSyncProgress) { + private fun handleProgress(userWallet: UserWallet, current: AssetsDiscoveryProgress) { val progressUM = when (current) { - is TokenSyncProgress.InProgress -> TokenSyncProgressUM.InProgress(current.progressPercent) - is TokenSyncProgress.Completed -> TokenSyncProgressUM.Completed - is TokenSyncProgress.Idle -> TokenSyncProgressUM.Idle + is AssetsDiscoveryProgress.InProgress -> AssetsDiscoveryProgressUM.InProgress(current.progressPercent) + is AssetsDiscoveryProgress.Completed -> AssetsDiscoveryProgressUM.Completed + is AssetsDiscoveryProgress.Idle -> AssetsDiscoveryProgressUM.Idle } stateController.update( - SetTokenSyncProgressTransformer( + SetAssetsDiscoveryProgressTransformer( userWallet = userWallet, progress = progressUM, ), @@ -40,6 +40,6 @@ internal class TokenSyncSubscriber @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(userWallet: UserWallet): TokenSyncSubscriber + fun create(userWallet: UserWallet): AssetsDiscoverySubscriber } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt index 6192849dc4..047b5d765d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt @@ -34,6 +34,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { abstract val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase abstract val stateController: WalletStateController abstract val clickIntents: WalletClickIntents + abstract val isAddAndManageTokensEnabled: Boolean override val singleAccountStatusListSupplier: SingleAccountStatusListSupplier get() = accountDependencies.singleAccountStatusListSupplier @@ -105,6 +106,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = isAccountMode, isRedesignEnabled = true, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ), ) } @@ -168,6 +170,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = false, isRedesignEnabled = false, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt index 88e0697c7e..ca409e79c6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt @@ -5,6 +5,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,8 +19,12 @@ internal class SingleWalletSubscriber @AssistedInject constructor( override val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, override val stateController: WalletStateController, override val clickIntents: WalletClickIntents, + private val walletFeatureToggles: WalletFeatureToggles, ) : BasicAccountListSubscriber() { + override val isAddAndManageTokensEnabled: Boolean + get() = walletFeatureToggles.isAddAndManageTokensEnabled + override fun create(coroutineScope: CoroutineScope): Flow = combine( flow = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt index 1a31f1f1b4..0ad3c442ba 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt @@ -5,6 +5,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -19,8 +20,12 @@ internal class SingleWalletWithTokenSubscriberLegacy @AssistedInject constructor override val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, override val stateController: WalletStateController, override val clickIntents: WalletClickIntents, + private val walletFeatureToggles: WalletFeatureToggles, ) : BasicAccountListSubscriber() { + override val isAddAndManageTokensEnabled: Boolean + get() = walletFeatureToggles.isAddAndManageTokensEnabled + override fun create(coroutineScope: CoroutineScope): Flow = combine( flow = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt index cc818aa976..e58aa664f5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt @@ -1,117 +1,38 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.MainCustomerInfoContentState -import com.tangem.domain.pay.model.MainScreenCustomerInfo -import com.tangem.domain.pay.model.TangemPayCustomerInfoError -import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.TangemPayWithdrawRepository -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletTangemPayAnalyticsEventSender -import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger -@Suppress("LongParameterList") internal class TangemPayMainSubscriber @AssistedInject constructor( @Assisted private val userWallet: UserWallet, - private val stateController: WalletStateController, - private val clickIntents: WalletClickIntents, - private val cardDetailsRepository: TangemPayCardDetailsRepository, - private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase, private val tangemPayWithdrawRepository: TangemPayWithdrawRepository, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val analytics: WalletTangemPayAnalyticsEventSender, ) : WalletSubscriber() { override fun create(coroutineScope: CoroutineScope): Flow<*> { coroutineScope.launch { + // TODO: Doston move this logic to proper place(e.g. WalletBalanceFetcher) tangemPayWithdrawRepository.pollWithdrawOrdersIfNeeds(userWallet) } - return subscribeOnTangemPayInfoUpdates() + subscribeToStatus(coroutineScope) + return emptyFlow() } - private fun subscribeOnTangemPayInfoUpdates(): Flow<*> { - return tangemPayMainScreenCustomerInfoUseCase(userWalletId = userWallet.walletId) + private fun subscribeToStatus(coroutineScope: CoroutineScope) { + paymentAccountStatusSupplier.invoke(userWalletId = userWallet.walletId) + .map { it.value } .distinctUntilChanged() - .onEach { mainInfoData -> - val userWalletId = userWallet.walletId - mainInfoData.onLeft { tangemPayError -> - when (tangemPayError) { - TangemPayCustomerInfoError.RefreshNeededError -> { - stateController.update( - transformer = TangemPayRefreshNeededStateTransformer( - userWalletId = userWalletId, - userWallet = userWallet, - onRefreshClick = { clickIntents.onRefreshPayToken(userWallet) }, - ), - ) - } - TangemPayCustomerInfoError.UnavailableError -> { - stateController.update( - transformer = TangemPayUnavailableStateTransformer(userWalletId), - ) - } - TangemPayCustomerInfoError.ExposedDeviceError -> { - stateController.update(TangemPayExposedDeviceTransformer(userWalletId)) - } - TangemPayCustomerInfoError.UnknownError -> { - // hide TangemPay block - TangemLogger.e("Failed when loading main screen TangemPay info: $tangemPayError") - stateController.update( - transformer = TangemPayHiddenStateTransformer(userWalletId), - ) - } - } - }.onRight { contentState -> handleContentState(state = contentState) } - } - } - - private suspend fun handleContentState(state: MainCustomerInfoContentState) { - val userWalletId = userWallet.walletId - when (state) { - MainCustomerInfoContentState.Loading -> stateController.update( - transformer = TangemPayLoadingStateTransformer(userWalletId), - ) - is MainCustomerInfoContentState.Content -> { - updateTangemPay(data = state.info, userWalletId = userWalletId) - analytics.send(customerInfo = state.info) - } - is MainCustomerInfoContentState.OnboardingBanner -> stateController.update( - transformer = TangemPayOnboardingBannerStateTransformer( - userWalletId = userWalletId, - onClick = clickIntents::onOnboardingBannerClick, - closeOnClick = clickIntents::onOnboardingBannerCloseClick, - ), - ) - is MainCustomerInfoContentState.Empty -> stateController.update( - transformer = TangemPayHiddenStateTransformer(userWalletId), - ) - } - } - - private suspend fun updateTangemPay(data: MainScreenCustomerInfo, userWalletId: UserWalletId) { - val cardFrozenState = - data.info.productInstance?.cardId?.let { cardDetailsRepository.cardFrozenStateSync(it) } - ?: TangemPayCardFrozenState.Unfrozen - stateController.update( - transformer = TangemPayUpdateInfoStateTransformer( - userWalletId = userWalletId, - value = data, - cardFrozenState = cardFrozenState, - tangemPayClickIntents = clickIntents, - ), - ) + .onEach(analytics::send) + .launchIn(coroutineScope) } @AssistedFactory diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 8f842b82cf..23de72403e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -82,7 +82,6 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.* import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.nftCollections import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock -import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.TangemPayMainScreenBlock import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator import com.tangem.features.tangempay.component.TangemPayMainBlockComponent import com.tangem.features.tangempay.entity.TangemPayMainUM @@ -726,17 +725,13 @@ private fun WalletSnackbarHost( } internal fun LazyListScope.organizeTokens(state: WalletState, itemModifier: Modifier) { - (state as? WalletState.MultiCurrency)?.let { - (state.tokensListState as? WalletTokensListState.ContentState)?.let { - it.organizeTokensButtonConfig?.let { config -> - organizeTokensButton( - modifier = itemModifier, - isEnabled = config.isEnabled, - onClick = config.onClick, - ) - } - } - } + val multiCurrencyState = state as? WalletState.MultiCurrency ?: return + val contentState = multiCurrencyState.tokensListState as? WalletTokensListState.ContentState ?: return + val config = contentState.organizeTokensButtonConfig ?: return + organizeTokensButton( + modifier = itemModifier, + config = config, + ) } internal fun LazyListScope.nftCollections(state: WalletState, itemModifier: Modifier) { @@ -756,14 +751,8 @@ internal fun LazyListScope.tangemPayItem( ) { if (state !is WalletState.MultiCurrency) return - if (state.isTangemPayRefactorEnabled) { - with(tangemPayComponent) { - tangemPayMainContent(modifier = modifier, state = state.tangemPayMainUM, isBalanceHidden = isHidingMode) - } - } else { - item(key = "TangemPayMainScreenBlock", contentType = state.tangemPayState::class.java) { - TangemPayMainScreenBlock(modifier = modifier, state = state.tangemPayState, isBalanceHidden = isHidingMode) - } + with(tangemPayComponent) { + tangemPayMainContent(modifier = modifier, state = state.tangemPayMainUM, isBalanceHidden = isHidingMode) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt index 41663f384a..9e1eef6fba 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt @@ -9,9 +9,9 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -25,25 +25,26 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.ds.button.SecondaryTangemButton -import com.tangem.core.ui.ds.button.TangemButtonShape import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.button.action.ActionButtons import com.tangem.core.ui.ds.image.TangemDeviceIcon import com.tangem.core.ui.ds.placeholder.TextPlaceholder import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed -import com.tangem.core.ui.extensions.orEmpty import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.preview.WalletBalancePreview import com.tangem.feature.wallet.presentation.preview.WalletPreviewData +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM -import com.tangem.feature.wallet.presentation.wallet.ui.components.fastForEach import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList @@ -84,17 +85,7 @@ internal fun WalletBalance( isBalanceHidden = isBalanceHidden, ) SpacerH(TangemTheme.dimens2.x3) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), - ) { - Text( - text = walletBalanceUM.name, - style = TangemTheme.typography2.bodyRegular14, - color = TangemTheme.colors2.text.neutral.tertiary, - ) - TangemDeviceIcon(state = walletBalanceUM.deviceIcon) - } + SubtitleRow(walletBalanceUM = walletBalanceUM) } SpacerH(TangemTheme.dimens2.x2) ActionButtons(buttons) @@ -102,6 +93,61 @@ internal fun WalletBalance( } } +@Composable +private fun SubtitleRow(walletBalanceUM: WalletBalanceUM, modifier: Modifier = Modifier) { + AnimatedContent( + targetState = walletBalanceUM.additionalInfo?.content, + contentKey = { content -> + when (content) { + is WalletAdditionalInfo.Content.SyncProgress -> WalletAdditionalInfo.Content.SyncProgress::class + else -> content + } + }, + label = "Update subtitle", + modifier = modifier, + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 90)) + }, + ) { content -> + when (content) { + is WalletAdditionalInfo.Content.SyncProgress -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1_5), + ) { + Text( + text = resourceReference( + id = R.string.initial_wallet_sync_restore_progress, + formatArgs = wrappedList(content.progressPercent), + ).resolveReference(), + style = TangemTheme.typography2.bodyRegular14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + CircularProgressIndicator( + modifier = Modifier.size(19.dp), + color = TangemTheme.colors2.graphic.neutral.primary, + strokeWidth = 2.dp, + ) + } + } + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + text = walletBalanceUM.name, + style = TangemTheme.typography2.bodyRegular14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + TangemDeviceIcon(state = walletBalanceUM.deviceIcon) + } + } + } + } +} + @Composable private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { AnimatedContent( @@ -152,41 +198,6 @@ private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean, } } -@Composable -private fun ActionButtons(buttons: ImmutableList) { - Row( - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - buttons.fastForEach { button -> - key(button.text) { - val textColor = if (button.isEnabled) { - TangemTheme.colors2.text.neutral.primary - } else { - TangemTheme.colors2.text.status.disabled - } - Column( - modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SecondaryTangemButton( - tangemIconUM = button.tangemIconUM, - onClick = button.onClick, - isEnabled = button.isEnabled, - shape = TangemButtonShape.Rounded, - ) - Text( - text = button.text.orEmpty().resolveReference(), - style = TangemTheme.typography2.bodySemibold15, - color = textColor, - ) - } - } - } - } -} - // region Preview @Composable @Preview(showBackground = true, widthDp = 360) @@ -215,6 +226,7 @@ private class WalletBalancePreviewProvider : PreviewParameterProvider get() = sequenceOf( WalletBalancePreviewData(WalletBalancePreview.content, WalletPreviewData.actionButtons), + WalletBalancePreviewData(WalletBalancePreview.syncProgress, WalletPreviewData.actionButtons), WalletBalancePreviewData(WalletBalancePreview.hiddenBalanceContent, WalletPreviewData.actionButtons), WalletBalancePreviewData(WalletBalancePreview.loading, WalletPreviewData.disabledActionButtons), WalletBalancePreviewData(WalletBalancePreview.error, WalletPreviewData.disabledActionButtons), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt index 94592fc246..6e5177817c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt @@ -7,7 +7,7 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.buttons.actions.RoundedActionButton import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.test.MainScreenTestTags -import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton" @@ -20,18 +20,17 @@ private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton" [REDACTED_AUTHOR] */ internal fun LazyListScope.organizeTokensButton( - isEnabled: Boolean, - onClick: () -> Unit, + config: WalletTokensListState.OrganizeTokensButtonConfig, modifier: Modifier = Modifier, ) { item(key = ORGANIZE_BUTTON_CONTENT_TYPE, contentType = ORGANIZE_BUTTON_CONTENT_TYPE) { RoundedActionButton( modifier = modifier.testTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON), config = ActionButtonConfig( - text = resourceReference(id = R.string.organize_tokens_title), - iconResId = R.drawable.ic_filter_24, - onClick = onClick, - isEnabled = isEnabled, + text = resourceReference(id = config.textRes), + iconResId = config.iconRes, + onClick = config.onClick, + isEnabled = config.isEnabled, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/singlecurrency/TangemPayCardMainBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/singlecurrency/TangemPayCardMainBlock.kt deleted file mode 100644 index c155df8fd4..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/singlecurrency/TangemPayCardMainBlock.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency - -import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.tangem.common.ui.R -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.TangemPayMainScreenBlock - -@Composable -internal fun TangemPayCardMainBlock( - state: TangemPayState.Card, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = TangemTheme.shapes.roundedCornersXMedium, - color = TangemTheme.colors.background.primary, - onClick = state.onClick, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .padding(horizontal = 12.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Image( - painter = painterResource(R.drawable.img_visa_36), - contentDescription = null, - modifier = Modifier.size(36.dp), - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = stringResourceSafe(R.string.tangempay_payment_account), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - ) - Text( - text = state.lastFourDigits.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } - Column( - modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(2.dp), - horizontalAlignment = Alignment.End, - ) { - Text( - text = state.balanceText.resolveReference().orMaskWithStars(isBalanceHidden), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.End, - ) - Text( - text = state.balanceSymbol.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.End, - ) - } - } - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayCardMainBlockPreview() { - TangemThemePreview { - TangemPayMainScreenBlock( - TangemPayState.Card( - lastFourDigits = TextReference.Str("*1234"), - balanceText = TextReference.Str("$ 0.00"), - balanceSymbol = TextReference.Str("USDC"), - onClick = {}, - ), - isBalanceHidden = false, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayExposedDeviceState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayExposedDeviceState.kt deleted file mode 100644 index 6ba66da79f..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayExposedDeviceState.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import com.tangem.core.ui.R -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme - -private const val DISABLED_ALPHA = 0.6F - -@Composable -internal fun TangemPayExposedDeviceState(modifier: Modifier = Modifier) { - BlockCard( - modifier = modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary) - .alpha(DISABLED_ALPHA), - enabled = false, - onClick = {}, - ) { - InputRowImageBase( - modifier = Modifier - .padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = resourceReference(R.string.tangempay_payment_account), - caption = resourceReference(R.string.tangem_pay_rooted_device_subtitle), - subtitleColor = TangemTheme.colors.text.primary1, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - endIconTint = TangemTheme.colors.icon.warning, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayFailedIssueState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayFailedIssueState.kt deleted file mode 100644 index df4c7bfafd..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayFailedIssueState.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState - -@Composable -internal fun TangemPayFailedIssueState(state: TangemPayState.FailedIssue, modifier: Modifier = Modifier) { - BlockCard( - modifier = modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - onClick = state.onButtonClick, - ) { - InputRowImageBase( - modifier = Modifier - .padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = state.title, - caption = state.description, - subtitleColor = TangemTheme.colors.text.primary1, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = com.tangem.core.ui.R.drawable.img_visa_36, - iconEndRes = state.iconRes, - endIconTint = TangemTheme.colors.icon.warning, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayLoadingScreenBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayLoadingScreenBlock.kt deleted file mode 100644 index a2dbe13375..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayLoadingScreenBlock.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.CircleShimmer -import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.res.TangemTheme - -@Composable -internal fun TangemPayLoadingScreenBlock(modifier: Modifier = Modifier) { - Row( - modifier = modifier - .fillMaxWidth() - .background(color = TangemTheme.colors.background.primary, shape = TangemTheme.shapes.roundedCornersXMedium) - .padding(horizontal = 12.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircleShimmer(modifier = Modifier.size(36.dp)) - Column( - modifier = Modifier.padding(start = 12.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - RectangleShimmer(modifier = Modifier.padding(vertical = 4.dp).sizeIn(minWidth = 70.dp, minHeight = 12.dp)) - RectangleShimmer(modifier = Modifier.padding(vertical = 2.dp).sizeIn(minWidth = 52.dp, minHeight = 12.dp)) - } - SpacerWMax() - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - RectangleShimmer(modifier = Modifier.padding(vertical = 4.dp).sizeIn(minWidth = 40.dp, minHeight = 12.dp)) - RectangleShimmer(modifier = Modifier.padding(vertical = 2.dp).sizeIn(minWidth = 40.dp, minHeight = 12.dp)) - } - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayMainScreenBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayMainScreenBlock.kt deleted file mode 100644 index 862b6c770d..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayMainScreenBlock.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.common.ui.R -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded -import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.TangemPayCardMainBlock - -@Composable -internal fun TangemPayMainScreenBlock(state: TangemPayState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { - when (state) { - is Progress -> TangemPayProgressState(state, modifier) - is TangemPayState.Card -> TangemPayCardMainBlock(state, isBalanceHidden, modifier) - is TangemPayState.Empty -> Unit - is TangemPayState.RefreshNeeded -> TangemPayRefreshBlock(state, modifier) - is TangemPayState.TemporaryUnavailable -> TangemPayUnavailableBlock(state, modifier) - is TangemPayState.FailedIssue -> TangemPayFailedIssueState(state, modifier) - is TangemPayState.OnboardingBanner -> TangemPayOnboardingBanner(state, modifier) - is TangemPayState.ExposedDevice -> TangemPayExposedDeviceState(modifier) - is TangemPayState.Loading -> TangemPayLoadingScreenBlock(modifier) - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayMainScreenBlockPreview() { - TangemThemePreview { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - TangemPayMainScreenBlock(state = TangemPayState.Loading, isBalanceHidden = false) - TangemPayMainScreenBlock( - state = TangemPayState.RefreshNeeded( - TangemPayRefreshNeeded( - buttonText = resourceReference(id = R.string.home_button_scan), - onRefreshClick = {}, - shouldShowProgress = false, - ), - ), - isBalanceHidden = false, - ) - TangemPayMainScreenBlock( - state = TangemPayState.TemporaryUnavailable(WalletNotification.Warning.TangemPayUnreachable), - isBalanceHidden = false, - ) - TangemPayMainScreenBlock( - state = TangemPayState.OnboardingBanner(onClick = {}, closeOnClick = {}), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock(state = TangemPayState.ExposedDevice, isBalanceHidden = false) - TangemPayMainScreenBlock( - state = TangemPayState.FailedIssue( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_failed_to_issue_card), - iconRes = R.drawable.ic_alert_24, - onButtonClick = { }, - ), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock( - Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_kyc_in_progress), - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = {}, - ), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock( - Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_issuing_your_card), - buttonText = TextReference.EMPTY, - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = {}, - showProgress = true, - ), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock( - TangemPayState.Card( - lastFourDigits = TextReference.Str("*1234"), - balanceText = TextReference.Str("$ 0.00"), - balanceSymbol = TextReference.Str("USDC"), - onClick = {}, - ), - isBalanceHidden = false, - ) - } - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayOnboardingBanner.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayOnboardingBanner.kt deleted file mode 100644 index d345eede90..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayOnboardingBanner.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState - -private const val GRADIENT_START_COLOR = 0xFF252934 -private const val GRADIENT_END_COLOR = 0xFF12141E -private const val GRADIENT_OFFSET_X = 0f -private const val GRADIENT_OFFSET_Y = 80F -private const val GRADIENT_RADIUS = 200F - -@Composable -internal fun TangemPayOnboardingBanner(state: TangemPayState.OnboardingBanner, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxWidth() - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background( - brush = Brush.radialGradient( - colors = listOf(Color(GRADIENT_START_COLOR), Color(GRADIENT_END_COLOR)), - center = Offset(GRADIENT_OFFSET_X, GRADIENT_OFFSET_Y), - radius = GRADIENT_RADIUS, - ), - ) - .clickable(onClick = state.onClick), - ) { - ConstraintLayout( - modifier = Modifier.fillMaxWidth(), - ) { - val (image, text, close) = createRefs() - - Image( - painter = painterResource(R.drawable.ic_close_24), - contentDescription = null, - modifier = Modifier - .size(16.dp) - .clickable(onClick = state.closeOnClick) - .constrainAs(close) { - top.linkTo(parent.top, margin = 16.dp) - end.linkTo(parent.end, margin = 16.dp) - }, - colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), - ) - - Column( - modifier = Modifier - .constrainAs(text) { - top.linkTo(parent.top) - start.linkTo(image.end, margin = 12.dp) - end.linkTo(close.start, margin = 12.dp) - width = Dimension.fillToConstraints - } - .padding(top = 16.dp, bottom = 16.dp), - ) { - Text( - text = stringResourceSafe(R.string.tangempay_onboarding_banner_title), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.constantWhite, - ) - - SpacerH(6.dp) - - Text( - text = stringResourceSafe(R.string.tangempay_onboarding_banner_description), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } - - Image( - painter = painterResource(R.drawable.img_tangem_pay_onboarding_banner), - contentDescription = null, - modifier = Modifier - .padding(top = 8.dp, start = 24.dp) - .constrainAs(image) { - start.linkTo(parent.start) - top.linkTo(text.top) - bottom.linkTo(text.bottom) - height = Dimension.fillToConstraints - }, - ) - } - } -} - -@Preview(showBackground = true) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun PreviewTangemOnboardingBanner() { - TangemThemePreview { - TangemPayOnboardingBanner( - TangemPayState.OnboardingBanner( - onClick = {}, - closeOnClick = {}, - ), - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayProgressState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayProgressState.kt deleted file mode 100644 index a73e842c3b..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayProgressState.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import com.tangem.core.ui.R -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState - -@Composable -internal fun TangemPayProgressState(state: TangemPayState.Progress, modifier: Modifier = Modifier) { - BlockCard( - modifier = modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - onClick = state.onButtonClick, - ) { - InputRowImageBase( - modifier = Modifier - .padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = state.title, - caption = state.description, - subtitleColor = TangemTheme.colors.text.primary1, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayRefreshBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayRefreshBlock.kt deleted file mode 100644 index 715a6168a2..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayRefreshBlock.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded - -@Composable -internal fun TangemPayRefreshBlock(state: TangemPayState.RefreshNeeded, modifier: Modifier = Modifier) { - Column(modifier) { - Notification( - config = state.notification.config, - iconTint = when (state.notification) { - is WalletNotification.Critical -> TangemTheme.colors.icon.warning - is WalletNotification.Informational -> TangemTheme.colors.icon.accent - is WalletNotification.RateApp -> TangemTheme.colors.icon.attention - is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 - is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention - else -> null - }, - ) - SpacerH12() - - BlockCard( - modifier = Modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - enabled = false, - ) { - InputRowImageBase( - modifier = Modifier.padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = resourceReference(R.string.tangempay_payment_account), - caption = resourceReference(R.string.tangempay_payment_account_sync_needed), - subtitleColor = TangemTheme.colors.text.tertiary, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - ) - } - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayRefreshBlockPreview() { - TangemThemePreview { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - TangemPayRefreshBlock( - state = TangemPayState.RefreshNeeded( - TangemPayRefreshNeeded( - buttonText = resourceReference(id = R.string.tangempay_sync_needed_restore_access), - onRefreshClick = {}, - shouldShowProgress = true, - ), - ), - modifier = Modifier, - ) - } - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemUnavailableBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemUnavailableBlock.kt deleted file mode 100644 index a8628de815..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemUnavailableBlock.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.utils.StringsSigns.DASH_SIGN - -@Composable -internal fun TangemPayUnavailableBlock(state: TangemPayState.TemporaryUnavailable, modifier: Modifier = Modifier) { - Column(modifier) { - Notification( - config = state.notification.config, - iconTint = when (state.notification) { - is WalletNotification.Critical -> TangemTheme.colors.icon.warning - is WalletNotification.Informational -> TangemTheme.colors.icon.accent - is WalletNotification.RateApp -> TangemTheme.colors.icon.attention - is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 - is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention - else -> null - }, - ) - SpacerH12() - - BlockCard( - modifier = Modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - enabled = false, - ) { - InputRowImageBase( - modifier = Modifier.padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = resourceReference(R.string.tangempay_payment_account), - caption = TextReference.Str(DASH_SIGN), - subtitleColor = TangemTheme.colors.text.tertiary, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - ) - } - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayUnavailableBlockPreview() { - TangemThemePreview { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - TangemPayUnavailableBlock( - state = TangemPayState.TemporaryUnavailable( - WalletNotification.Warning.TangemPayUnreachable, - ), - modifier = Modifier, - ) - } - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt index 2df2e0c850..cd26b7f591 100644 --- a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt +++ b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt @@ -27,7 +27,7 @@ class YieldSupplyPromoBannerConverterTest { @Test fun `GIVEN promo disabled WHEN convert THEN return null`() { - val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xABCDEF") + val token = createToken(networkId = "ethereum", rawId = "ethereum", contract = "0xABCDEF") val status = createLoadedStatus(token = token, amount = BigDecimal.ONE, isYieldActive = false) val tokenList = ungroupedTokenList(status) val params = TokenConverterParams.Wallet( @@ -35,7 +35,7 @@ class YieldSupplyPromoBannerConverterTest { tokenList = tokenList, ) val converter = YieldSupplyPromoBannerConverter( - yieldModuleApyMap = mapOf("${token.network.backendId}_${token.contractAddress}" to BigDecimal("0.10")), + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.10")), shouldShowMainPromo = false, ) @@ -46,7 +46,7 @@ class YieldSupplyPromoBannerConverterTest { @Test fun `GIVEN empty apy map WHEN convert THEN return null`() { - val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xA1") + val token = createToken(networkId = "ethereum", rawId = "ethereum", contract = "0xA1") val status = createLoadedStatus(token = token, amount = BigDecimal("2.0"), isYieldActive = false) val params = TokenConverterParams.Wallet( mainAccount = account, @@ -64,14 +64,14 @@ class YieldSupplyPromoBannerConverterTest { @Test fun `GIVEN active yield token present WHEN convert THEN return null`() { - val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xAA") + val token = createToken(networkId = "ethereum", rawId = "ethereum", contract = "0xAA") val statusActive = createLoadedStatus(token = token, amount = BigDecimal("5"), isYieldActive = true) val params = TokenConverterParams.Wallet( mainAccount = account, tokenList = ungroupedTokenList(statusActive), ) val converter = YieldSupplyPromoBannerConverter( - yieldModuleApyMap = mapOf("${token.network.backendId}_${token.contractAddress}" to BigDecimal("0.12")), + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.12")), shouldShowMainPromo = true, ) @@ -82,16 +82,16 @@ class YieldSupplyPromoBannerConverterTest { @Test fun `GIVEN multiple candidates EVM case insensitive WHEN convert THEN return status of max amount`() { - val evmNetworkId = "ETH" - val tokenSmall = createToken(networkId = evmNetworkId, backendId = evmNetworkId, contract = "0xAbCd") - val tokenBig = createToken(networkId = evmNetworkId, backendId = evmNetworkId, contract = "0xBEEF") + val evmNetworkId = "ethereum" + val tokenSmall = createToken(networkId = evmNetworkId, rawId = evmNetworkId, contract = "0xAbCd") + val tokenBig = createToken(networkId = evmNetworkId, rawId = evmNetworkId, contract = "0xBEEF") val statusSmall = createLoadedStatus(token = tokenSmall, amount = BigDecimal("1.00"), isYieldActive = false) val statusBig = createLoadedStatus(token = tokenBig, amount = BigDecimal("10.00"), isYieldActive = false) val apyMap = mapOf( - "${tokenSmall.network.backendId}_${tokenSmall.contractAddress.lowercase()}" to BigDecimal("0.05"), - "${tokenBig.network.backendId}_${tokenBig.contractAddress.uppercase()}" to BigDecimal("0.15"), + "${tokenSmall.network.rawId}_${tokenSmall.contractAddress.lowercase()}" to BigDecimal("0.05"), + "${tokenBig.network.rawId}_${tokenBig.contractAddress.uppercase()}" to BigDecimal("0.15"), ) val params = TokenConverterParams.Wallet( @@ -111,10 +111,10 @@ class YieldSupplyPromoBannerConverterTest { @Test fun `GIVEN non evm case sensitive mismatch WHEN convert THEN return null`() { val nonEvmId = "xrp" - val token = createToken(networkId = nonEvmId, backendId = nonEvmId, contract = "rAbC123") + val token = createToken(networkId = nonEvmId, rawId = nonEvmId, contract = "rAbC123") val status = createLoadedStatus(token = token, amount = BigDecimal("3"), isYieldActive = false) - val mismatchedKey = "${token.network.backendId}_${token.contractAddress.lowercase()}" + val mismatchedKey = "${token.network.rawId}_${token.contractAddress.lowercase()}" val apyMap = mapOf(mismatchedKey to BigDecimal("0.07")) val params = TokenConverterParams.Wallet( @@ -133,14 +133,14 @@ class YieldSupplyPromoBannerConverterTest { @Test fun `GIVEN custom status WHEN convert THEN return null`() { - val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xCUSTOM") + val token = createToken(networkId = "ethereum", rawId = "ethereum", contract = "0xCUSTOM") val status = createCustomStatus(token = token, amount = BigDecimal("5.0"), isYieldActive = false) val params = TokenConverterParams.Wallet( mainAccount = account, tokenList = ungroupedTokenList(status), ) val converter = YieldSupplyPromoBannerConverter( - yieldModuleApyMap = mapOf("${token.network.backendId}_${token.contractAddress}" to BigDecimal("0.10")), + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.10")), shouldShowMainPromo = true, ) @@ -236,15 +236,14 @@ class YieldSupplyPromoBannerConverterTest { ) } - private fun createToken(networkId: String, backendId: String, contract: String): CryptoCurrency.Token { + private fun createToken(networkId: String, rawId: String, contract: String): CryptoCurrency.Token { val network = Network( - id = Network.ID(value = networkId, derivationPath = Network.DerivationPath.None), - backendId = backendId, - name = backendId, + id = Network.ID(value = rawId, derivationPath = Network.DerivationPath.None), + name = rawId, currencySymbol = "SYM", derivationPath = Network.DerivationPath.None, isTestnet = false, - standardType = when (backendId) { + standardType = when (rawId) { "ethereum" -> Network.StandardType.ERC20 else -> Network.StandardType.Unspecified("UNSPEC") }, diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index de88d92e2a..a2e0fbda2f 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -834,10 +834,9 @@ class DefaultPromoDeeplinkHandlerTest { address: String, derivationPath: Network.DerivationPath = Network.DerivationPath.None, ): NetworkStatus { - val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) + val networkId = Network.ID(value = rawNetworkId, derivationPath = derivationPath) val network = Network( id = networkId, - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, @@ -866,10 +865,9 @@ class DefaultPromoDeeplinkHandlerTest { } private fun buildUnreachableNetworkStatus(rawNetworkId: String): NetworkStatus { - val networkId = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None) + val networkId = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None) val network = Network( id = networkId, - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, @@ -890,10 +888,9 @@ class DefaultPromoDeeplinkHandlerTest { rawNetworkId: String, derivationPath: Network.DerivationPath = Network.DerivationPath.None, ): CryptoCurrency.Coin { - val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) + val networkId = Network.ID(value = rawNetworkId, derivationPath = derivationPath) val network = Network( id = networkId, - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt index 5b6afef517..9f687cf8d5 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt @@ -285,8 +285,7 @@ internal class QrContentClassifierTest { private fun buildNetwork(rawNetworkId: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts index 3c857c76b5..2548c1f7b0 100644 --- a/features/walletconnect/impl/build.gradle.kts +++ b/features/walletconnect/impl/build.gradle.kts @@ -12,7 +12,7 @@ android { } dependencies { - implementation(projects.features.account.api) + implementation(projects.features.commonFeatures.api) implementation(projects.features.wallet.api) implementation(projects.features.walletconnect.api) implementation(projects.features.sendV2.api) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt index a160555493..0ab64bd444 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt @@ -16,7 +16,7 @@ import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.walletconnect.connections.model.WcPairModel import com.tangem.features.walletconnect.connections.routes.WcAppInfoRoutes import com.tangem.features.walletconnect.connections.routes.WcAppInfoRoutes.Alert diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt index bef2fd2b82..4ce0752d54 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt @@ -8,7 +8,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index d51b17e8c5..c6c3e4f486 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -34,9 +34,9 @@ import com.tangem.domain.walletconnect.model.WcSessionProposal import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import com.tangem.domain.walletconnect.usecase.pair.WcPairState import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.walletconnect.connections.components.WcPairComponent import com.tangem.features.walletconnect.connections.components.WcSelectNetworksComponent import com.tangem.features.walletconnect.connections.entity.WcAppInfoUM diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt index f87b309971..6731ddb78e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt @@ -1,12 +1,12 @@ package com.tangem.features.walletconnect.connections.model import androidx.compose.runtime.Stable +import com.tangem.common.ui.extensions.greyedOutIconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router -import com.tangem.core.ui.extensions.getGreyedOutIconRes -import com.tangem.core.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.connections.components.WcSelectNetworksComponent.WcSelectNetworksParams import com.tangem.features.walletconnect.connections.entity.WcNetworkInfoItem @@ -67,7 +67,7 @@ internal class WcSelectNetworksModel @Inject constructor( missing = params.missingRequiredNetworks.map { network -> WcNetworkInfoItem.Required( id = network.rawId, - icon = getGreyedOutIconRes(network.rawId), + icon = network.greyedOutIconResId, name = network.name, symbol = network.currencySymbol, ) @@ -95,7 +95,7 @@ internal class WcSelectNetworksModel @Inject constructor( notAdded = params.notAddedNetworks.map { network -> WcNetworkInfoItem.ReadOnly( id = network.rawId, - icon = getGreyedOutIconRes(network.rawId), + icon = network.greyedOutIconResId, name = network.name, symbol = network.currencySymbol, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt index ee51c67235..d57b9716e4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.walletconnect.connections.model.transformers -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.connections.entity.WcNetworkInfoItem import com.tangem.features.walletconnect.connections.entity.WcNetworksInfo diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt index 7a3bbca637..ca9fe402ed 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt @@ -14,13 +14,14 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.send.v2.api.FeeSelectorComponent import com.tangem.features.walletconnect.components.WcRoutingComponent import com.tangem.features.walletconnect.connections.components.AlertsComponent import com.tangem.features.walletconnect.connections.components.AlertsComponent.AlertType.* import com.tangem.features.walletconnect.connections.components.WcPairComponent +import com.tangem.features.walletconnect.transaction.components.addresses.WcGetAddressesComponent import com.tangem.features.walletconnect.transaction.components.chain.WcAddNetworkContainerComponent import com.tangem.features.walletconnect.transaction.components.chain.WcSwitchNetworkComponent import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams @@ -79,6 +80,10 @@ internal class DefaultWcRoutingComponent @AssistedInject constructor( appComponentContext = childContext, params = WcTransactionModelParams(config.rawRequest), ) + is WcInnerRoute.GetAddresses -> WcGetAddressesComponent( + appComponentContext = childContext, + params = WcTransactionModelParams(config.rawRequest), + ) is WcInnerRoute.Send -> WcSendTransactionContainerComponent( appComponentContext = childContext, params = WcTransactionModelParams(config.rawRequest), diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt index bc9f386e88..637069b54e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt @@ -25,6 +25,9 @@ internal sealed interface WcInnerRoute : Route { @Serializable data class SwitchNetwork(override val rawRequest: WcSdkSessionRequest) : Method + @Serializable + data class GetAddresses(override val rawRequest: WcSdkSessionRequest) : Method + @Serializable data class Pair(val request: WcPairRequest) : WcInnerRoute diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt index 341ab307f2..ddfb9f8b3b 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt @@ -7,11 +7,13 @@ import com.tangem.core.decompose.model.Model import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService +import com.tangem.domain.walletconnect.model.WcBitcoinMethodName import com.tangem.domain.walletconnect.model.WcEthMethodName import com.tangem.domain.walletconnect.model.WcMethodName import com.tangem.domain.walletconnect.model.WcSolanaMethodName import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* +import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @ModelScoped @@ -32,6 +34,7 @@ internal class WcRoutingModel @Inject constructor( } fun onSlotEmpty() { + TangemLogger.d("WC Queue: onSlotEmpty() called") isSlotEmpty.update { true } } @@ -44,18 +47,35 @@ internal class WcRoutingModel @Inject constructor( WcEthMethodName.SignTypeData, WcEthMethodName.SignTypeDataV4, WcSolanaMethodName.SignMessage, - -> WcInnerRoute.SignMessage(rawRequest) + WcBitcoinMethodName.SignMessage, + -> { + WcInnerRoute.SignMessage(rawRequest) + } WcEthMethodName.AddEthereumChain, - -> WcInnerRoute.AddNetwork(rawRequest) + -> { + WcInnerRoute.AddNetwork(rawRequest) + } WcEthMethodName.SwitchEthereumChain, - -> WcInnerRoute.SwitchNetwork(rawRequest) + -> { + WcInnerRoute.SwitchNetwork(rawRequest) + } WcEthMethodName.SignTransaction, WcEthMethodName.SendTransaction, WcSolanaMethodName.SignTransaction, WcSolanaMethodName.SendAllTransaction, - -> WcInnerRoute.Send(rawRequest) + WcBitcoinMethodName.SendTransfer, + WcBitcoinMethodName.SignPsbt, + -> { + WcInnerRoute.Send(rawRequest) + } + WcBitcoinMethodName.GetAccountAddresses, + -> { + WcInnerRoute.GetAddresses(rawRequest) + } is WcMethodName.Unsupported, - -> WcInnerRoute.UnsupportedMethodAlert + -> { + WcInnerRoute.UnsupportedMethodAlert + } } } @@ -64,7 +84,9 @@ internal class WcRoutingModel @Inject constructor( merge(requestFlow, pairFlow) .onEach { configuration -> + TangemLogger.d("WC Queue: Received configuration $configuration, waiting for queue ready") awaitQueueReady() + TangemLogger.d("WC Queue: Queue ready, pushing configuration") isSlotEmpty.update { false } innerRouter.push(configuration) } @@ -76,7 +98,12 @@ internal class WcRoutingModel @Inject constructor( permittedAppRoute, cardSdkProvider.sdk.uiVisibility(), ) { isSlotEmpty, permittedAppRoute, isCardSdkVisible -> - isSlotEmpty && permittedAppRoute && !isCardSdkVisible + val isReady = isSlotEmpty && permittedAppRoute && !isCardSdkVisible + TangemLogger.d( + "WC Queue: isSlotEmpty=$isSlotEmpty, permittedAppRoute=$permittedAppRoute, " + + "isCardSdkVisible=$isCardSdkVisible, ready=$isReady", + ) + isReady }.first { it } fun onAppRouteChange(appRoute: AppRoute) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt index 620599339d..66f4e4fb8d 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.features.walletconnect.connections.model.* import com.tangem.features.walletconnect.connections.routing.WcRoutingModel import com.tangem.features.walletconnect.transaction.model.WcAddNetworkModel +import com.tangem.features.walletconnect.transaction.model.WcGetAddressesModel import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel import com.tangem.features.walletconnect.transaction.model.WcSignTransactionModel import com.tangem.features.walletconnect.transaction.model.WcSwitchNetworkModel @@ -53,6 +54,11 @@ internal interface WalletConnectModelModule { @ClassKey(WcAddNetworkModel::class) fun bindWcAddNetworkModel(model: WcAddNetworkModel): Model + @Binds + @IntoMap + @ClassKey(WcGetAddressesModel::class) + fun bindWcGetAddressesModel(model: WcGetAddressesModel): Model + @Binds @IntoMap @ClassKey(WcSwitchNetworkModel::class) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/addresses/WcGetAddressesComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/addresses/WcGetAddressesComponent.kt new file mode 100644 index 0000000000..b57c6484cc --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/addresses/WcGetAddressesComponent.kt @@ -0,0 +1,25 @@ +package com.tangem.features.walletconnect.transaction.components.addresses + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams +import com.tangem.features.walletconnect.transaction.model.WcGetAddressesModel + +/** + * Component for Bitcoin getAccountAddresses WalletConnect method. + */ +internal class WcGetAddressesComponent( + appComponentContext: AppComponentContext, + params: WcTransactionModelParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Suppress("UnusedPrivateProperty") + private val model: WcGetAddressesModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt index bc884a209f..d82c809cf4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestBlockUM import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoItemUM import com.tangem.utils.converter.Converter +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.toImmutableList import org.json.JSONArray import org.json.JSONObject @@ -58,7 +59,16 @@ internal class TransactionParamsConverter @Inject constructor() : Converter loop(JSONArray(value)) + '{' -> loop(JSONObject(value)) + else -> loop(JSONArray(value)) // default to array for backward compatibility + } + } catch (e: Exception) { + TangemLogger.withTag("Wallet Connect").e("Failed to parse transaction params: ${e.message.orEmpty()}") + } + return result } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt index 31b58c91d4..90abbf6d74 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.walletconnect.transaction.converter -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM import com.tangem.utils.converter.Converter @@ -10,6 +10,6 @@ internal class WcNetworkInfoUMConverter @Inject constructor() : Converter { override fun convert(value: Input): WcSendTransactionUM? { @@ -41,6 +40,9 @@ internal class WcSendTransactionUMConverter @Inject constructor( is WcEthMethod.SignTransaction, is WcSolanaMethod.SignAllTransaction, is WcSolanaMethod.SignTransaction, + is WcBitcoinMethod.SendTransfer, + is WcBitcoinMethod.SignPsbt, + is WcBitcoinMethod.SignMessage, -> WcSendTransactionUM( transaction = WcSendTransactionItemUM( onDismiss = value.actions.onDismiss, @@ -65,8 +67,7 @@ internal class WcSendTransactionUMConverter @Inject constructor( } }, feeErrorNotification = feeErrorNotification, - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - value.context.session.wallet.isHotWallet, + isHoldToConfirmEnabled = value.context.session.wallet.isHotWallet, ), feeSelectorUM = when (value.feeState) { WcTransactionFeeState.None -> FeeSelectorUM.Loading diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt index fcccd0f7f8..4fd75570a0 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt @@ -10,7 +10,6 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionItemUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionUM -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isHotWallet import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -20,7 +19,6 @@ internal class WcSignTransactionUMConverter @Inject constructor( private val appInfoContentUMConverter: WcTransactionAppInfoContentUMConverter, private val networkInfoUMConverter: WcNetworkInfoUMConverter, private val requestBlockUMConverter: WcTransactionRequestBlockUMConverter, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Converter { override fun convert(value: Input) = WcSignTransactionUM( @@ -38,8 +36,7 @@ internal class WcSignTransactionUMConverter @Inject constructor( isLoading = value.signState.domainStep == WcSignStep.Signing, address = WcAddressConverter.convert(value.context.derivationState), walletInteractionIcon = walletInterationIcon(value.context.session.wallet), - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - value.context.session.wallet.isHotWallet, + isHoldToConfirmEnabled = value.context.session.wallet.isHotWallet, ), transactionRequestInfo = WcTransactionRequestInfoUM( requestBlockUMConverter.convert( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt index 5a8f8d3fc3..6f185ecfd9 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt @@ -10,7 +10,6 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionItemUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionUM -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isHotWallet import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -20,7 +19,6 @@ internal class WcSignTypedDataUMConverter @Inject constructor( private val appInfoContentUMConverter: WcTransactionAppInfoContentUMConverter, private val networkInfoUMConverter: WcNetworkInfoUMConverter, private val requestBlockUMConverter: WcTransactionRequestBlockUMConverter, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Converter { override fun convert(value: Input): WcSignTransactionUM = WcSignTransactionUM( @@ -38,8 +36,7 @@ internal class WcSignTypedDataUMConverter @Inject constructor( address = WcAddressConverter.convert(value.context.derivationState), isLoading = value.signState.domainStep == WcSignStep.Signing, walletInteractionIcon = walletInterationIcon(value.context.session.wallet), - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - value.context.session.wallet.isHotWallet, + isHoldToConfirmEnabled = value.context.session.wallet.isHotWallet, ), transactionRequestInfo = WcTransactionRequestInfoUM( blocks = buildList { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt new file mode 100644 index 0000000000..05b5667a53 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.walletconnect.transaction.entity.addresses + +import androidx.annotation.DrawableRes +import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM +import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionAppInfoContentUM + +/** + * UI model for Bitcoin getAccountAddresses WalletConnect request. + */ +internal data class WcGetAddressesUM( + val appInfo: WcTransactionAppInfoContentUM, + val networkInfo: WcNetworkInfoUM, + val addresses: List, + val isLoading: Boolean, + @DrawableRes val walletInteractionIcon: Int, + val onApprove: () -> Unit, + val onReject: () -> Unit, +) { + data class AddressInfo( + val address: String, + val intention: String?, + ) +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcGetAddressesModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcGetAddressesModel.kt new file mode 100644 index 0000000000..11efd9327d --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcGetAddressesModel.kt @@ -0,0 +1,46 @@ +package com.tangem.features.walletconnect.transaction.model + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.walletconnect.WcRequestUseCaseFactory +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.usecase.method.WcGetAddressesUseCase +import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams +import com.tangem.features.walletconnect.transaction.converter.WcHandleMethodErrorConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Model for Bitcoin getAccountAddresses WalletConnect method. + */ +@Stable +@ModelScoped +internal class WcGetAddressesModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val useCaseFactory: WcRequestUseCaseFactory, +) : Model() { + + private val params = paramsContainer.require() + + init { + modelScope.launch { + val useCase = useCaseFactory.createUseCase(params.rawRequest) + .onLeft { showErrorDialog(it) } + .getOrNull() ?: return@launch + + useCase.invoke() + .onLeft { showErrorDialog(it) } + .onRight { router.pop() } + } + } + + private fun showErrorDialog(error: HandleMethodError) { + router.push(WcHandleMethodErrorConverter.convert(error)) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index b6c43240ab..85ce399ac9 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -12,6 +12,7 @@ import com.domain.blockaid.models.transaction.SimulationResult import com.domain.blockaid.models.transaction.ValidationResult import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -59,7 +60,6 @@ import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransacti import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes import com.tangem.features.walletconnect.transaction.ui.blockaid.WcSendAndReceiveBlockAidUiConverter import com.tangem.features.walletconnect.utils.WcNotificationsFactory -import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -301,14 +301,10 @@ internal class WcSendTransactionModel @Inject constructor( stackNavigation.pop() } - @Deprecated("Use TangemBlockUrlBuilder instead") private fun onApproveLearnMoreClick() { - val code = SupportedLanguages.getCurrentSupportedLanguageCode() - .takeIf { it == SupportedLanguages.RUSSIAN } - ?: SupportedLanguages.ENGLISH - - val url = "https://tangem.com/$code/blog/post/give-revoke-permission/" - urlOpener.openUrl(url) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission)) + } } private fun isMultipleSignRequired(useCase: WcSignUseCase<*>): Boolean { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt index 2f36707a60..16074b661c 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt @@ -35,6 +35,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import com.tangem.utils.logging.TangemLogger +import com.tangem.domain.walletconnect.WC_TAG import javax.inject.Inject import kotlin.properties.Delegates @@ -67,14 +69,36 @@ internal class WcSignTransactionModel @Inject constructor( init { modelScope.launch { + TangemLogger.withTag(WC_TAG).i("Creating use case...") useCase = useCaseFactory.createUseCase(params.rawRequest) - .onLeft { router.push(WcHandleMethodErrorConverter.convert(it)) } - .getOrNull() ?: return@launch + .onLeft { error -> + TangemLogger.withTag(WC_TAG).e("Failed to create use case: $error") + router.push(WcHandleMethodErrorConverter.convert(error)) + } + .getOrNull() ?: run { + TangemLogger.withTag(WC_TAG).e("Use case is null, exiting") + return@launch + } + + TangemLogger.withTag(WC_TAG).i("Use case created successfully") + TangemLogger.withTag(WC_TAG).i("Use case type: ${useCase.javaClass.simpleName}") + TangemLogger.withTag(WC_TAG).i("Method: ${useCase.method}") + sendSignatureReceivedAnalytics(useCase) + + TangemLogger.withTag(WC_TAG).i("Invoking use case...") useCase.invoke() .onEach { signState -> - if (signingIsDone(signState)) return@onEach + TangemLogger.withTag(WC_TAG).i("Sign state received: ${signState.javaClass.simpleName}") + + if (signingIsDone(signState)) { + TangemLogger.withTag(WC_TAG).i("Signing is DONE, not updating UI") + return@onEach + } + + TangemLogger.withTag(WC_TAG).i("Converting to UI state...") val signTransactionUM = convertToUI(useCase, signState) + TangemLogger.withTag(WC_TAG).i("UI state created, emitting...") _uiState.emit(signTransactionUM) } .launchIn(this) @@ -101,7 +125,10 @@ internal class WcSignTransactionModel @Inject constructor( portfolioName = portfolioNameDelegate.createAccountTitleUM(useCase.session), ), ) - is WcEthMethod.MessageSign, is WcSolanaMethod.SignMessage -> signTransactionUMConverter.convert( + is WcEthMethod.MessageSign, + is WcSolanaMethod.SignMessage, + is com.tangem.domain.walletconnect.model.WcBitcoinMethod.SignMessage, + -> signTransactionUMConverter.convert( WcSignTransactionUMConverter.Input( context = useCase, signState = signState, @@ -110,7 +137,10 @@ internal class WcSignTransactionModel @Inject constructor( portfolioName = portfolioNameDelegate.createAccountTitleUM(useCase.session), ), ) - else -> null + else -> { + TangemLogger.withTag(WC_TAG).e("UNSUPPORTED METHOD: ${useCase.method.javaClass.simpleName}") + null + } } } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt index e79abdf12d..2eccb57458 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/blockaid/WcSpendAllowanceUMConverter.kt @@ -1,7 +1,8 @@ package com.tangem.features.walletconnect.transaction.ui.blockaid import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.domain.walletconnect.model.WcApprovedAmount import com.tangem.features.walletconnect.impl.R import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM @@ -22,7 +23,7 @@ internal class WcSpendAllowanceUMConverter : Converter @@ -91,6 +93,8 @@ internal class WelcomeModel @Inject constructor( private val walletsFetcherJobHolder = JobHolder() private val wallets = MutableStateFlow>(persistentListOf()) private var routedOut = false + private val isWalletCreationRestrictionEnabled: StateFlow = + hotWalletRestrictionManager.isCreationEnabled() init { modelScope.launch { @@ -182,7 +186,7 @@ internal class WelcomeModel @Inject constructor( private fun addWalletClick() { analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.SignIn)) - if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled) { + if (isWalletCreationRestrictionEnabled.value) { scanCard() } else { router.push(AppRoute.CreateWalletSelection) @@ -213,7 +217,18 @@ internal class WelcomeModel @Inject constructor( } } .onRight { - router.replaceAll(AppRoute.Wallet) + val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWallet.walletId, + isWalletStarted = false, + ), + ) + } else { + AppRoute.Wallet + } + router.replaceAll(route) } }, onCancel = {}, diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index d71b9cf36a..4bf8eda135 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.yield.supply.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Feature */ @@ -20,6 +24,7 @@ dependencies { implementation(projects.core.configToggles) implementation(projects.core.datasource) implementation(projects.core.decompose) + implementation(projects.core.res) implementation(projects.core.ui) implementation(projects.core.navigation) implementation(projects.core.analytics) @@ -75,4 +80,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index 92b9ebf190..4da4c1922b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -4,6 +4,7 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -34,13 +35,12 @@ import com.tangem.features.yield.supply.impl.active.model.transformers.YieldSupp import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyApproveComponent import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent import com.tangem.utils.StringsSigns.DASH_SIGN -import com.tangem.utils.TangemBlogUrlBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -160,7 +160,9 @@ internal class YieldSupplyActiveModel @Inject constructor( } fun onReadMoreClick() { - urlOpener.openUrl(TangemBlogUrlBuilder.YIELD_SUPPLY_HOW_IT_WORKS_URL) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowYieldModeWorks)) + } } private fun subscribeOnCurrencyStatusUpdates() { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt index cec6ab682a..d6de0e0316 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt @@ -61,8 +61,7 @@ class YieldSupplyAlertFactory @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency?.network?.rawId.orEmpty(), - derivationPath = cryptoCurrency?.network?.derivationPath?.value.orEmpty(), + networkId = cryptoCurrency?.network?.id, tokenSymbol = cryptoCurrency?.symbol.orEmpty(), destinationAddress = "", amount = "", diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt index 2473062b59..15c94cd9ac 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt @@ -4,11 +4,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.earn.EarnBlock import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.impl.main.model.YieldSupplyModel -import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContent +import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContentLegacy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,9 +24,13 @@ internal class DefaultYieldSupplyComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val yieldSupplyUM by model.uiState.collectAsStateWithLifecycle() - - YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, modifier = modifier) + if (LocalRedesignEnabled.current) { + val earnBlockUM by model.uiState.collectAsStateWithLifecycle() + earnBlockUM?.let { EarnBlock(state = it, modifier = modifier) } + } else { + val yieldSupplyUM by model.uiStateLegacy.collectAsStateWithLifecycle() + YieldSupplyBlockContentLegacy(yieldSupplyUM = yieldSupplyUM, modifier = modifier) + } } @AssistedFactory diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 70194d75cc..03dbe46227 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -30,7 +30,9 @@ import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R +import com.tangem.common.ui.earn.EarnBlockUM import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.features.yield.supply.impl.main.model.converter.YieldSupplyToEarnBlockConverter import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.transformer.update @@ -61,11 +63,16 @@ internal class YieldSupplyModel @Inject constructor( private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, ) : Model(), YieldSupplyClickIntents { + private val earnBlockConverter = YieldSupplyToEarnBlockConverter() private val params = paramsContainer.require() - val uiState: StateFlow + val uiStateLegacy: StateFlow field = MutableStateFlow(YieldSupplyUM.Initial) + val uiState: StateFlow = uiStateLegacy + .map(earnBlockConverter::convert) + .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) + private val cryptoCurrency = params.cryptoCurrency private var appCurrency: AppCurrency = AppCurrency.Default var userWallet: UserWallet by Delegates.notNull() @@ -143,7 +150,7 @@ internal class YieldSupplyModel @Inject constructor( val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> - uiState.update( + uiStateLegacy.update( YieldSupplyTokenStatusSuccessTransformer( tokenStatus = tokenStatus, onStartEarningClick = ::onStartEarningClick, @@ -151,7 +158,7 @@ internal class YieldSupplyModel @Inject constructor( ) }.onLeft { error -> TangemLogger.e("Error", error) - uiState.update { YieldSupplyUM.Initial } + uiStateLegacy.update { YieldSupplyUM.Initial } } } @@ -165,7 +172,7 @@ internal class YieldSupplyModel @Inject constructor( private fun navigateToYieldSupplyEntry() { val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return - val apy = when (val yieldSupplyUM = uiState.value) { + val apy = when (val yieldSupplyUM = uiStateLegacy.value) { is YieldSupplyUM.Available -> yieldSupplyUM.apy is YieldSupplyUM.Content -> yieldSupplyUM.apy else -> "" @@ -181,8 +188,8 @@ internal class YieldSupplyModel @Inject constructor( private suspend fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) { val isCryptoCurrencyStatusFromCache = cryptoCurrencyStatus.value.sources.networkSource != StatusSource.ACTUAL - val processing = uiState.value is YieldSupplyUM.Processing - if (isCryptoCurrencyStatusFromCache && processing) { + val isProcessing = uiStateLegacy.value is YieldSupplyUM.Processing + if (isCryptoCurrencyStatusFromCache && isProcessing) { return } @@ -199,7 +206,7 @@ internal class YieldSupplyModel @Inject constructor( } private fun showProcessing(status: YieldSupplyPendingStatus) { - uiState.update { + uiStateLegacy.update { when (status) { is YieldSupplyPendingStatus.Enter -> YieldSupplyUM.Processing.Enter is YieldSupplyPendingStatus.Exit -> YieldSupplyUM.Processing.Exit @@ -225,7 +232,7 @@ internal class YieldSupplyModel @Inject constructor( ) { val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return val showWarningIcon = !yieldSupplyStatus.isAllowedToSpend - val isShowInfoIconPrevState = when (val state = uiState.value) { + val isShowInfoIconPrevState = when (val state = uiStateLegacy.value) { is YieldSupplyUM.Content -> state.showInfoIcon else -> false } @@ -239,7 +246,7 @@ internal class YieldSupplyModel @Inject constructor( } yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> - uiState.update { + uiStateLegacy.update { YieldSupplyUM.Content( title = resourceReference( R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, @@ -262,7 +269,7 @@ internal class YieldSupplyModel @Inject constructor( computeAndApplyShowInfoIcon(cryptoCurrencyStatus) }.onLeft { t -> TangemLogger.e("Error", t) - uiState.update { + uiStateLegacy.update { YieldSupplyUM.Content( title = resourceReference( R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, @@ -299,7 +306,7 @@ internal class YieldSupplyModel @Inject constructor( } else { false } - uiState.update { state -> + uiStateLegacy.update { state -> when (state) { is YieldSupplyUM.Content -> state.copy(showInfoIcon = isShowInfoIcon) else -> state diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt new file mode 100644 index 0000000000..1a86207510 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt @@ -0,0 +1,129 @@ +package com.tangem.features.yield.supply.impl.main.model.converter + +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.utils.converter.Converter +import com.tangem.core.res.R as CoreResR +import com.tangem.core.ui.R as CoreUiR + +internal class YieldSupplyToEarnBlockConverter : Converter { + + override fun convert(value: YieldSupplyUM): EarnBlockUM? = when (value) { + is YieldSupplyUM.Initial, + is YieldSupplyUM.Unavailable, + -> null + is YieldSupplyUM.Available -> buildAvailable(value) + is YieldSupplyUM.Content -> buildContent(value) + is YieldSupplyUM.Processing.Enter -> buildProcessingEnter() + is YieldSupplyUM.Processing.Exit -> buildProcessingExit() + is YieldSupplyUM.Loading -> EarnBlockUM.Loading + } + + private fun buildAvailable(value: YieldSupplyUM.Available): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference( + id = CoreResR.string.yield_module_token_details_earn_notification_subtitle, + formatArgs = wrappedList(value.apy), + ), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference( + CoreResR.string.yield_module_token_details_earn_notification_description, + ), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Accent, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + text = resourceReference(CoreResR.string.common_more), + ), + onClick = value.onClick, + ) + } + + private fun buildContent(value: YieldSupplyUM.Content): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40), + titleUM = EarnBlockUM.TitleUM( + text = if (value.showWarningIcon) { + resourceReference(CoreResR.string.common_yield_mode) + } else { + resourceReference(CoreResR.string.yield_module_transaction_enter) + }, + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference( + id = CoreResR.string.yield_module_average_apy, + formatArgs = wrappedList(value.apy), + ), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Accent, + ), + trailingUM = buildContentTrailing(value), + onClick = value.onClick, + ) + } + + private fun buildContentTrailing(value: YieldSupplyUM.Content): EarnBlockUM.TrailingUM? = when { + value.showWarningIcon -> EarnBlockUM.TrailingUM.Icon( + tone = EarnBlockUM.TrailingUM.IconTone.Warning, + ) + value.showInfoIcon -> EarnBlockUM.TrailingUM.Icon( + tone = EarnBlockUM.TrailingUM.IconTone.Info, + ) + else -> EarnBlockUM.TrailingUM.Button( + text = resourceReference(CoreResR.string.details_title), + ) + } + + private fun buildProcessingEnter(): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.common_yield_mode), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.common_enabling), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Accent, + loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive), + ), + trailingUM = null, + ) + } + + private fun buildProcessingExit(): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_yield_disabling_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.common_yield_mode), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.common_disabling), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Disabled, + loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted), + ), + trailingUM = null, + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt similarity index 98% rename from features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt rename to features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt index 36a30575b7..7286fa3c1e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt @@ -38,7 +38,7 @@ import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.utils.StringsSigns @Composable -internal fun YieldSupplyBlockContent(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) { +internal fun YieldSupplyBlockContentLegacy(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) { AnimatedContent( targetState = yieldSupplyUM, contentKey = { it::class }, @@ -330,7 +330,7 @@ private fun SupplyInfo( @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::class) params: YieldSupplyUM) { TangemThemePreview { - YieldSupplyBlockContent(yieldSupplyUM = params, modifier = Modifier) + YieldSupplyBlockContentLegacy(yieldSupplyUM = params, modifier = Modifier) } } diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt index a1384942e1..438a5ceb15 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.yield.supply.impl.promo.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -11,13 +12,12 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent -import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics +import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.promo.YieldSupplyPromoConfig import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM -import com.tangem.utils.TangemBlogUrlBuilder -import com.tangem.utils.TangemBlogUrlBuilder.YIELD_SUPPLY_HOW_IT_WORKS_URL import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped @@ -32,8 +32,8 @@ internal class YieldSupplyPromoModel @Inject constructor( val params: YieldSupplyPromoComponent.Params = paramsContainer.require() val uiState: YieldSupplyPromoUM = YieldSupplyPromoUM( - tosLink = TangemBlogUrlBuilder.YIELD_SUPPLY_TOS_URL, - policyLink = TangemBlogUrlBuilder.YIELD_SUPPLY_PRIVACY_URL, + tosLink = AAVE_TOS_URL, + policyLink = AAVE_PRIVACY_URL, tokenSymbol = params.currency.symbol, title = resourceReference( R.string.yield_module_promo_screen_title_v2, @@ -69,10 +69,17 @@ internal class YieldSupplyPromoModel @Inject constructor( } override fun onHowItWorksClick() { - urlOpener.openUrl(YIELD_SUPPLY_HOW_IT_WORKS_URL) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowYieldModeWorks)) + } } override fun onStartEarningClick() { bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action) } + + private companion object { + const val AAVE_TOS_URL = "https://aave.com/terms-of-service" + const val AAVE_PRIVACY_URL = "https://aave.com/privacy-policy" + } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt index a4b555063f..1cbf1b303f 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt @@ -1,9 +1,10 @@ package com.tangem.features.yield.supply.impl.subcomponents.approve.model import arrow.core.getOrElse -import com.tangem.utils.logging.TangemLogger import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic @@ -11,8 +12,6 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -41,8 +40,8 @@ import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyAp import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsComponent import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsUpdateTrigger import com.tangem.features.yield.supply.impl.subcomponents.notifications.entity.YieldSupplyNotificationData -import com.tangem.utils.TangemBlogUrlBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.* @@ -65,7 +64,6 @@ internal class YieldSupplyApproveModel @Inject constructor( private val yieldSupplyGetContractAddressUseCase: YieldSupplyGetContractAddressUseCase, private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyApproveComponent.Params = paramsContainer.require() @@ -102,8 +100,7 @@ internal class YieldSupplyApproveModel @Inject constructor( yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, isPrimaryButtonEnabled = false, isTransactionSending = false, - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - params.userWallet.isHotWallet, + isHoldToConfirmEnabled = params.userWallet.isHotWallet, ), ) @@ -116,7 +113,9 @@ internal class YieldSupplyApproveModel @Inject constructor( } fun onReadMoreClick() { - urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee)) + } } override fun onFeeReload() { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt index 122487f1e7..a08903b346 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt @@ -61,7 +61,7 @@ internal class YieldSupplyNotificationsModel @Inject constructor( cryptoCurrencyWarning = cryptoCurrencyWarning, cryptoCurrencyStatus = cryptoCurrencyStatus, shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum( - blockchainId = cryptoCurrencyStatus.currency.network.backendId, + networkId = cryptoCurrencyStatus.currency.network.rawId, ), onClick = ::openTokenDetails, onAnalyticsEvent = { /*no-op*/ }, diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt index 62a991685c..e5dc348279 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningEntryModel.kt @@ -5,7 +5,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 4824105018..c850cb0581 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -1,16 +1,14 @@ package com.tangem.features.yield.supply.impl.subcomponents.startearning.model import arrow.core.getOrElse -import com.tangem.utils.logging.TangemLogger import com.tangem.blockchain.common.TransactionSender +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.datasource.local.appsflyer.AppsFlyerStore @@ -42,6 +40,7 @@ import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSup import com.tangem.features.yield.supply.impl.subcomponents.startearning.model.transformers.YieldSupplyStartEarningFeeContentTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -71,7 +70,6 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private val yieldSupplyRepository: YieldSupplyRepository, private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, private val appsFlyerStore: AppsFlyerStore, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() @@ -317,8 +315,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( userWallet = wallet uiState.update { it.copy( - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - wallet.isHotWallet, + isHoldToConfirmEnabled = wallet.isHotWallet, ) } getCurrenciesStatusUpdates() diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index 2d62921de0..0a339baa9b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -1,6 +1,8 @@ package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model import arrow.core.getOrElse +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic @@ -8,17 +10,15 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.yield.supply.INCREASE_GAS_LIMIT_FOR_SUPPLY @@ -40,13 +40,12 @@ import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSu import com.tangem.features.yield.supply.impl.subcomponents.notifications.entity.YieldSupplyNotificationData import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent import com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.transformer.YieldSupplyStopEarningFeeContentTransformer -import com.tangem.utils.TangemBlogUrlBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList") @@ -67,7 +66,6 @@ internal class YieldSupplyStopEarningModel @Inject constructor( private val yieldSupplyRepository: YieldSupplyRepository, private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, private val appsFlyerStore: AppsFlyerStore, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() @@ -104,8 +102,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, isPrimaryButtonEnabled = false, isTransactionSending = false, - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - params.userWallet.isHotWallet, + isHoldToConfirmEnabled = params.userWallet.isHotWallet, ), ) @@ -131,7 +128,9 @@ internal class YieldSupplyStopEarningModel @Inject constructor( } fun onReadMoreClick() { - urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee)) + } } fun onClick() { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/model/YieldSupplyDepositedWarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/model/YieldSupplyDepositedWarningModel.kt index aa994f08e2..51e7b0b198 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/model/YieldSupplyDepositedWarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/warning/model/YieldSupplyDepositedWarningModel.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Stable import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import com.tangem.features.yield.supply.impl.warning.ui.YieldSupplyDepositedWarningUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt new file mode 100644 index 0000000000..3a44708e54 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt @@ -0,0 +1,186 @@ +package com.tangem.features.yield.supply.impl.main.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import org.junit.jupiter.api.Test + +internal class YieldSupplyToEarnBlockConverterTest { + + private val converter = YieldSupplyToEarnBlockConverter() + + @Test + fun `GIVEN Initial WHEN convert THEN null`() { + val result = converter.convert(YieldSupplyUM.Initial) + + assertThat(result).isNull() + } + + @Test + fun `GIVEN Unavailable WHEN convert THEN null`() { + val result = converter.convert(YieldSupplyUM.Unavailable) + + assertThat(result).isNull() + } + + @Test + fun `GIVEN Loading WHEN convert THEN EarnBlockUM Loading`() { + val result = converter.convert(YieldSupplyUM.Loading) + + assertThat(result).isEqualTo(EarnBlockUM.Loading) + } + + @Test + fun `GIVEN Content WHEN convert THEN Content`() { + var clicked = false + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = { clicked = true }, + showWarningIcon = false, + showInfoIcon = false, + ) + + val result = converter.convert(content) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface) + assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Glowing::class.java) + assertThat(earnBlock.titleUM.tone).isEqualTo(EarnBlockUM.TitleUM.Tone.Primary) + assertThat((earnBlock.subtitleUM as EarnBlockUM.SubtitleUM.Text).tone) + .isEqualTo(EarnBlockUM.SubtitleUM.Tone.Accent) + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) + val button = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Button + assertThat(button.isEnabled).isTrue() + assertThat(earnBlock.onClick).isNotNull() + earnBlock.onClick?.invoke() + assertThat(clicked).isTrue() + } + + @Test + fun `GIVEN Processing Enter WHEN convert THEN Surface Content`() { + val result = converter.convert(YieldSupplyUM.Processing.Enter) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface) + assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Glowing::class.java) + assertThat(earnBlock.trailingUM).isNull() + } + + @Test + fun `GIVEN Processing Exit WHEN convert THEN Surface Content`() { + val result = converter.convert(YieldSupplyUM.Processing.Exit) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface) + assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Plain::class.java) + assertThat(earnBlock.trailingUM).isNull() + } + + @Test + fun `GIVEN Content with showWarningIcon WHEN convert THEN trailing Warning Icon`() { + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = {}, + showWarningIcon = true, + showInfoIcon = false, + ) + + val result = converter.convert(content) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java) + val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon + assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Warning) + } + + @Test + fun `GIVEN Content with showInfoIcon WHEN convert THEN trailing Info Icon`() { + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = {}, + showWarningIcon = false, + showInfoIcon = true, + ) + + val result = converter.convert(content) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java) + val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon + assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Info) + } + + @Test + fun `GIVEN Content with both icons WHEN convert THEN Warning takes precedence`() { + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = {}, + showWarningIcon = true, + showInfoIcon = true, + ) + + val result = converter.convert(content) + + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java) + val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon + assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Warning) + } + + @Test + fun `GIVEN Available WHEN convert THEN Content with expected structure`() { + var clicked = false + val available = YieldSupplyUM.Available( + apy = "5.1", + apyText = stringReference("5.1 % APY"), + title = stringReference("Yield Mode"), + onClick = { clicked = true }, + ) + + val result = converter.convert(available) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result as EarnBlockUM.Content + + assertThat(content.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(content.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.AccentSoft) + assertThat(content.iconUM).isInstanceOf(EarnBlockUM.IconUM.Glowing::class.java) + + assertThat(content.titleUM.style).isEqualTo(EarnBlockUM.TitleUM.Style.Large) + assertThat(content.titleUM.tone).isEqualTo(EarnBlockUM.TitleUM.Tone.Primary) + + assertThat(content.subtitleUM).isInstanceOf(EarnBlockUM.SubtitleUM.Text::class.java) + val subtitle = content.subtitleUM as EarnBlockUM.SubtitleUM.Text + assertThat(subtitle.style).isEqualTo(EarnBlockUM.SubtitleUM.Style.Small) + assertThat(subtitle.tone).isEqualTo(EarnBlockUM.SubtitleUM.Tone.Accent) + + assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) + val button = content.trailingUM as EarnBlockUM.TrailingUM.Button + assertThat(button.isEnabled).isTrue() + + assertThat(content.onClick).isNotNull() + content.onClick?.invoke() + assertThat(clicked).isTrue() + } +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 32ce0df01b..5c04c70efa 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -73,11 +73,9 @@ lottie-compose = "6.6.0" moshi = "1.15.1" moshiAdaptersExt = "0.1.5" okhttp = "4.9.3" -rekotlin = "1.0.4" retrofit = "2.11.0" retrofitMoshiConverter = "2.9.0" spongycastleCryptoCore = "1.58.0.0" -kermit = "2.1.0" viewBindingDelegate = "1.5.9" xmlShimmer = "1.1.3" zxingQrCode = "3.5.1" @@ -86,7 +84,6 @@ kotlinDatetime = "0.6.2" arrow = "1.2.4" # 2.0.1 breaks the build reownCore = "1.4.11" reownWeb3 = "1.4.11" -prettyLogger = "2.2.0" okHttp-prettyLogging = "3.1.0" chucker = "4.2.0" mlKit-barcodeScanning = "17.3.0" @@ -229,6 +226,7 @@ test-espresso-intents = { module = "androidx.test.espresso:espresso-intents", ve test-junit = { module = "junit:junit", version.ref = "junit" } test-junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" } test-junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +test-junit5-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } test-junit-android = { module = "androidx.test.ext:junit", version.ref = "junitAndroidExt" } test-truth = { module = "com.google.truth:truth", version.ref = "truth" } test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } @@ -279,11 +277,9 @@ moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", ver okHttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okHttp-prettyLogging = { module = "com.github.ihsanbal:LoggingInterceptor", version.ref = "okHttp-prettyLogging" } spongecastle-core = { module = "com.madgag.spongycastle:core", version.ref = "spongycastleCryptoCore" } -reKotlin = { module = "org.rekotlin:rekotlin", version.ref = "rekotlin" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-response-type-keeper = { module = "com.squareup.retrofit2:response-type-keeper", version.ref = "retrofit" } retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofitMoshiConverter" } -kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } viewBindingDelegate = { module = "com.github.kirich1409:viewbindingpropertydelegate-noreflection", version.ref = "viewBindingDelegate" } xmlShimmer = { module = "com.github.skydoves:androidveil", version.ref = "xmlShimmer" } zxing-qrCore = { module = "com.google.zxing:core", version.ref = "zxingQrCode" } @@ -293,7 +289,6 @@ arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } arrow-fx = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } reownCore = { module = "com.reown:android-core", version.ref = "reownCore" } reownWeb3 = { module = "com.reown:walletkit", version.ref = "reownWeb3" } -prettyLogger = { module = "com.orhanobut:logger", version.ref = "prettyLogger" } chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" } chuckerStub = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" } mlKit-barcodeScanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlKit-barcodeScanning" } diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt index ffe4eec03d..cffad48379 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt @@ -1,5 +1,6 @@ package com.tangem.blockchainsdk +import com.tangem.blockchain.assetsdiscovery.AssetsDiscoveryServiceFactory import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.memo.MemoValidatorFactory @@ -18,4 +19,7 @@ interface BlockchainSDKFactory { /** Get [MemoValidatorFactory] synchronously */ suspend fun getMemoValidatorFactorySync(): MemoValidatorFactory? + + /** Get [AssetsDiscoveryServiceFactory] synchronously */ + suspend fun getAssetsDiscoveryServiceFactorySync(): AssetsDiscoveryServiceFactory? } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt index ed8efd0ebe..f08da11c5a 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt @@ -1,5 +1,6 @@ package com.tangem.blockchainsdk +import com.tangem.blockchain.assetsdiscovery.AssetsDiscoveryServiceFactory import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.memo.MemoValidatorFactory @@ -34,6 +35,8 @@ internal class DefaultBlockchainSDKFactory( private val walletManagerFactory: Flow = createWalletManagerFactory() private val memoValidatorFactory: Flow = createMemoValidatorFactory() + private val assetsDiscoveryServiceFactory: Flow = + createAssetsDiscoveryServiceFactory() override suspend fun init() { coroutineScope { @@ -45,6 +48,9 @@ internal class DefaultBlockchainSDKFactory( override suspend fun getMemoValidatorFactorySync(): MemoValidatorFactory? = memoValidatorFactory.firstOrNull() + override suspend fun getAssetsDiscoveryServiceFactorySync(): AssetsDiscoveryServiceFactory? = + assetsDiscoveryServiceFactory.firstOrNull() + private fun createWalletManagerFactory(): Flow { return combine( flow = flowOf(blockchainSdkConfig), @@ -56,6 +62,16 @@ internal class DefaultBlockchainSDKFactory( .stateIn(scope = mainScope, started = SharingStarted.Eagerly, initialValue = null) } + private fun createAssetsDiscoveryServiceFactory(): Flow { + return combine( + flow = flowOf(blockchainSdkConfig), + flow2 = blockchainProvidersTypesManager.get(), + ) { config, providerTypes -> + AssetsDiscoveryServiceFactory(config = config, providerTypes = providerTypes) + } + .stateIn(scope = mainScope, started = SharingStarted.Eagerly, initialValue = null) + } + private fun createMemoValidatorFactory(): Flow { return combine( flow = flowOf(blockchainSdkConfig), diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt index 203d2fddbd..e78329bd4a 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt @@ -1,6 +1,7 @@ package com.tangem.blockchainsdk.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network /** Converts [Network] to [Blockchain] */ @@ -10,4 +11,8 @@ fun Network.toBlockchain(): Blockchain = id.toBlockchain() fun Network.ID.toBlockchain(): Blockchain = rawId.toBlockchain() /** Converts [Network.RawID] to [Blockchain] */ -fun Network.RawID.toBlockchain(): Blockchain = Blockchain.fromId(id = value) \ No newline at end of file +fun Network.RawID.toBlockchain(): Blockchain = value.toBlockchain() + +fun CryptoCurrency.ID.toBlockchain(): Blockchain = rawNetworkId.toBlockchain() + +private fun String.toBlockchain(): Blockchain = Blockchain.fromNetworkId(this) ?: Blockchain.Unknown \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index b3108e4f2e..f3e5669802 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -28,112 +28,112 @@ object BlockchainUtils { /** Decodes XRP Blockchain address */ fun decodeRippleXAddress(xAddress: String, blockchainId: String): XrpTaggedAddress? { - return if (blockchainId == Blockchain.XRP.id && xAddress.firstOrNull() == XRP_X_ADDRESS) { + return if (blockchainId.toBlockchain() == Blockchain.XRP && xAddress.firstOrNull() == XRP_X_ADDRESS) { val decodedAddress = XrpAddressService.decodeXAddress(xAddress) - return decodedAddress?.let(XrpTaggedAddressConverter()::convert) + decodedAddress?.let(XrpTaggedAddressConverter()::convert) } else { null } } /** If current [networkId] is Bitcoin */ - fun isBitcoin(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isBitcoin(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet } - /** If current [networkId] is use custom fee */ - fun isUseBitcoinFeeConverter(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) - return isBitcoin(blockchainId) || blockchain == Blockchain.Fact0rn + /** Checks if the current [networkId] uses a custom fee converter */ + fun isUseBitcoinFeeConverter(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() + return isBitcoin(networkId) || blockchain == Blockchain.Fact0rn } - /** If current [blockchainId] is Tezos */ - fun isTezos(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + /** If current [networkId] is Tezos */ + fun isTezos(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Tezos } - fun isCardano(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isCardano(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Cardano } - /** If current [blockchainId] is BeaconChain */ - fun isBeaconChain(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + /** If current [networkId] is BeaconChain */ + fun isBeaconChain(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Binance || blockchain == Blockchain.BinanceTestnet } - /** If current [blockchainId] is Polygon */ - fun isPolygonChain(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + /** If current [networkId] is Polygon */ + fun isPolygonChain(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Polygon || blockchain == Blockchain.PolygonTestnet } - fun isTron(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isTron(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet } - fun isTon(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isTon(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet } fun isSupportedNetworkId( - blockchainId: String, + networkId: String, excludedBlockchains: ExcludedBlockchains, hotExcludedBlockchains: Set, hasOnlyHotWallets: Boolean = false, coinId: String? = null, contractAddress: String? = null, ): Boolean { - val blockchain = Blockchain.fromNetworkId(blockchainId) ?: return false + val blockchain = networkId.toBlockchain() ?: return false if (blockchain in excludedBlockchains) return false if (hasOnlyHotWallets && blockchain in hotExcludedBlockchains) return false if (!contractAddress.isNullOrEmpty()) { if (!blockchain.canHandleTokens()) return false - if (coinId != null && !isNotBlockedByTerraV1Filter(blockchainId, coinId)) return false + if (coinId != null && !isNotBlockedByTerraV1Filter(networkId, coinId)) return false } return true } - fun isArbitrum(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isArbitrum(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Arbitrum } - fun isSolana(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isSolana(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Solana } - fun isPolkadot(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isPolkadot(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Polkadot || blockchain == Blockchain.PolkadotTestnet } - fun isCosmos(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isCosmos(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Cosmos || blockchain == Blockchain.CosmosTestnet } - fun isBSC(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isBSC(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.BSC || blockchain == Blockchain.BSCTestnet } - fun isEthereum(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isEthereum(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet } - fun isClore(blockchainId: String): Boolean { - return Blockchain.fromId(blockchainId) == Blockchain.Clore + fun isClore(networkId: String): Boolean { + return networkId.toBlockchain() == Blockchain.Clore } data class BlockchainInfo( @@ -162,31 +162,31 @@ object BlockchainUtils { /** * Blockchains not affecting total balance counting on errors */ - fun isIncludeToBalanceOnError(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isIncludeToBalanceOnError(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return when (blockchain) { Blockchain.Binance, Blockchain.BinanceTestnet -> true else -> false } } - fun isIncludeStakingTotalBalance(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isIncludeStakingTotalBalance(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain != Blockchain.Cardano } - fun isStakingRewardUnavailable(blockchainId: String, isCoin: Boolean): Boolean { - val isP2PEthPool = isEthereum(blockchainId) && isCoin + fun isStakingRewardUnavailable(networkId: String, isCoin: Boolean): Boolean { + val isP2PEthPool = isEthereum(networkId) && isCoin - return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId) || isP2PEthPool + return isSolana(networkId) || isBSC(networkId) || isTon(networkId) || isP2PEthPool } /** Checks if the blockchain uses case-insensitive contract addresses */ - fun isCaseInsensitiveContractAddress(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isCaseInsensitiveContractAddress(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() - return blockchain.isEvm() + return blockchain?.isEvm() == true } private fun getNetworkStandardName(blockchain: Blockchain): String { @@ -222,9 +222,11 @@ object BlockchainUtils { /** * Checks if the given coin is Tether on Ethereum network, which may require special handling in some cases. */ - fun isTetherInEthereum(blockchainId: String, contractAddress: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + fun isTetherInEthereum(networkId: String, contractAddress: String): Boolean { + val blockchain = networkId.toBlockchain() return (blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet) && contractAddress.equals(TETHER_CONTRACT_ADDRESS, ignoreCase = true) } + + private fun String.toBlockchain(): Blockchain? = Blockchain.fromNetworkId(this) } \ No newline at end of file diff --git a/libs/tangem-sdk-api/build.gradle.kts b/libs/tangem-sdk-api/build.gradle.kts index bf167d3a08..1a6d4da84d 100644 --- a/libs/tangem-sdk-api/build.gradle.kts +++ b/libs/tangem-sdk-api/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.kotlin.serialization) alias(deps.plugins.hilt.android) id("configuration") } @@ -12,11 +11,7 @@ android { } dependencies { - implementation(projects.common) implementation(projects.domain.models) - implementation(projects.domain.card) - implementation(projects.domain.legacy) - implementation(projects.domain.wallets.models) implementation(projects.domain.visa.models) implementation(projects.core.configToggles) @@ -28,8 +23,6 @@ dependencies { exclude(module = "joda-time") } - /** Other libraries */ - /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt deleted file mode 100644 index f27fd2b825..0000000000 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.sdk.api.di - -import android.content.Context -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.operations.attestation.CardArtworksProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import java.io.File -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object CardSdkModule { - - @Provides - @Singleton - fun provideCardArtworksProvider( - sdkRepository: CardSdkConfigRepository, - @ApplicationContext context: Context, - ): CardArtworksProvider { - return CardArtworksProvider( - tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl }, - artworksDirectory = File( - context.getExternalFilesDir(null) ?: context.filesDir, - "card_artworks", - ).apply { mkdirs() }, - ) - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 9d4a5fcb46..1938b7587c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -321,6 +321,9 @@ include(":features:virtual-accounts:main:impl") include(":features:virtual-accounts:details:api") include(":features:virtual-accounts:details:impl") + +include(":features:common-features:api") +include(":features:common-features:impl") // endregion Feature modules // region Domain modules @@ -340,7 +343,7 @@ include(":domain:dynamic-addresses:models") include(":domain:settings") include(":domain:tokens") include(":domain:tokens:models") -include(":domain:tokensync") +include(":domain:assetsdiscovery") include(":domain:wallets") include(":domain:wallets:models") include(":domain:txhistory") @@ -409,7 +412,7 @@ include(":data:balance-hiding") include(":data:common") include(":data:card") include(":data:tokens") -include(":data:tokensync") +include(":data:assetsdiscovery") include(":data:settings") include(":data:txhistory") include(":data:wallets")