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 7c7411dc8a..6f398fb14f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -161,7 +161,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 +192,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) @@ -274,6 +274,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) 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..8ad9db91da 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -35,6 +35,7 @@ object TestConstants { 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 MARKETS_MAIN_NETWORK_SUFFIX = "MAIN" @@ -43,4 +44,14 @@ object TestConstants { const val USER_TOKENS_API_SCENARIO = "user_tokens_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" } \ 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/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..beb2b2d14f 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -15,11 +15,10 @@ fun BaseTestCase.scanCard( mockContent: MockContent? = null, isTwinsCard: Boolean = false, ) { - if (productType != null) { - MockProvider.setMocks(productType) - } - if (mockContent != null) { - MockProvider.setMocks(mockContent) + when { + mockContent != null -> MockProvider.setMocks(mockContent) + productType != null -> MockProvider.setMocks(productType) + else -> MockProvider.setMocks(ProductType.Wallet) } step("Click on 'Accept' button") { onDisclaimerScreen { acceptButton.clickWithAssertion() } @@ -60,6 +59,57 @@ fun BaseTestCase.openMainScreen( } } +fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String) { + step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + 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 diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index f7bcef3905..6f2931a598 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -3,13 +3,11 @@ package com.tangem.scenarios import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.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 io.qameta.allure.kotlin.Allure.step fun BaseTestCase.openSendScreen(tokenName: String, mockState: String = "") { @@ -72,8 +70,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 +84,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() } } 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..ff268b8969 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -64,6 +64,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/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/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/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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2ef3c24014..95ce7f4c75 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -232,6 +232,17 @@ 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..4905038b88 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -43,7 +43,6 @@ 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 @@ -151,7 +150,5 @@ interface ApplicationEntryPoint { fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory - fun getCustomerIoFeatureToggles(): CustomerIoFeatureToggles - fun getScanFailsRequester(): ScanFailsRequester } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index e03df1316b..39c1f273db 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -60,7 +60,6 @@ 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 @@ -236,9 +235,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val appsFlyerClientFactory: AppsFlyerClient.Factory get() = entryPoint.getAppsFlyerClientFactory() - private val customerIoFeatureToggles: CustomerIoFeatureToggles - get() = entryPoint.getCustomerIoFeatureToggles() - private val scanFailsRequester get() = entryPoint.getScanFailsRequester() @@ -415,9 +411,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. 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()) 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/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/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index bc16aae955..092abd503b 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -1,7 +1,6 @@ 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 @@ -10,7 +9,6 @@ fun appReducer(action: Action, state: AppState): AppState { return AppState( globalState = globalReducer(action, state), - detailsState = DetailsReducer.reduce(action, state), daggerGraphState = DaggerGraphReducer.reduce(action, state), ) } 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 index 89851458ac..d854ba1088 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -1,10 +1,6 @@ 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 @@ -12,7 +8,6 @@ import org.rekotlin.StateType data class AppState( val globalState: GlobalState = GlobalState(), - val detailsState: DetailsState = DetailsState(), val daggerGraphState: DaggerGraphState = DaggerGraphState(), ) : StateType { @@ -20,12 +15,9 @@ data class AppState( fun getMiddleware(): List> { return listOf( logMiddleware, - GlobalMiddleware.handler, - DetailsMiddleware().detailsMiddleware, LockUserWalletsTimerMiddleware().middleware, AccessCodeRequestPolicyMiddleware().middleware, DaggerGraphMiddleware.daggerGraphMiddleware, - LegacyMiddleware.legacyMiddleware, ) } } 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 index a36bd807b6..c446ff5206 100644 --- 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 @@ -1,6 +1,5 @@ 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 @@ -8,10 +7,5 @@ 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 index e46630172e..32164696dc 100644 --- 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 @@ -13,13 +13,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState { 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 index 01782e2e9a..843d9f2977 100644 --- 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 @@ -1,6 +1,5 @@ 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 @@ -9,7 +8,6 @@ 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 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/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/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/domain/AssetsDiscoveryDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt new file mode 100644 index 0000000000..1745d88c51 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt @@ -0,0 +1,52 @@ +package com.tangem.tap.di.domain + +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, + appCoroutineScope: AppCoroutineScope, + ): StartAssetsDiscoveryUseCase { + return StartAssetsDiscoveryUseCase( + assetsDiscoveryRepository = assetsDiscoveryRepository, + manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, + appCoroutineScope = appCoroutineScope, + ) + } +} \ No newline at end of file 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/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/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..59a5811c90 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,40 @@ 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, + "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 +58,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/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index cc707e1fce..548fadc6ad 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 @@ -33,7 +33,7 @@ class FinalizeTwinTask( visaCardScanHandler = null, visaCoroutineScope = null, shouldCheckIsAlreadyActivated = false, - isDynamicAddressesEnabled = false, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, onboardingV2FeatureToggles = null, ).run(session, callback) is CompletionResult.Failure -> 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/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index 285f2c46e9..198f3b63d8 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 @@ -188,7 +188,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/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index a200fea92f..588ae2ef38 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, ) @@ -457,7 +461,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, ) } 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..492d1d23ae 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,7 @@ 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.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -51,6 +52,7 @@ 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 permittedAppRoute = MutableStateFlow(false) @@ -148,8 +150,9 @@ 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() 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/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 0cb5e4db8f..43ba7a2555 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,7 @@ 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.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -55,7 +56,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 +87,11 @@ class DeepLinkFactoryTest { every { create(any(), 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,6 +109,7 @@ class DeepLinkFactoryTest { stakingDeepLink = stakingDeepLinkFactory, marketsDeepLink = marketsDeepLinkFactory, marketsTokenDetailDeepLink = marketsTokenDetailDeepLinkFactory, + marketsTokenExchangesDeepLink = marketsTokenExchangesDeepLinkFactory, buyDeepLink = buyDeepLinkFactory, sellDeepLink = sellDeepLinkFactory, swapDeepLink = swapDeepLinkFactory, @@ -308,7 +315,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" 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..e78861b261 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, } } @@ -252,7 +256,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 +267,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 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..359839b059 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" } 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..6fe8031620 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,7 @@ 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" } \ No newline at end of file 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/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/action/CryptoCurrencyData.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt new file mode 100644 index 0000000000..34ecb8e2f3 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt @@ -0,0 +1,11 @@ +package com.tangem.common.ui.markets.action + +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, +) \ 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/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt index c98c012df0..c2565c295b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.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.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( +sealed class QuickActionUM( val title: TextReference, val description: TextReference, @DrawableRes val icon: Int, 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..f0112cab38 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt @@ -0,0 +1,61 @@ +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.toImmutableList + +object QuickActionsConverter { + + fun quickActions(cryptoData: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): QuickActions { + return 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/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 85% 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..90f8b3281b 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,7 +115,7 @@ internal class TokenActionsHandler @AssistedInject constructor( } } - private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) { router.push( AppRoute.Swap( currencyFrom = cryptoCurrencyData.status.currency, @@ -128,7 +126,7 @@ internal class TokenActionsHandler @AssistedInject constructor( ) } - 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 +134,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 +148,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 +171,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/build.gradle.kts b/common/ui/build.gradle.kts index 71620a30d6..31eb3dc544 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) @@ -48,4 +52,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/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/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/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/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/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/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..d4422d4bf3 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 @@ -20,30 +20,10 @@ "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" @@ -56,14 +36,6 @@ "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 +45,7 @@ "version": "undefined" }, { - "name": "TOKEN_SYNC_ENABLED", + "name": "ASSETS_DISCOVERY_ENABLED", "version": "undefined" }, { @@ -87,5 +59,13 @@ { "name": "HEDERA_ERC20_ENABLED", "version": "5.37" + }, + { + "name": "ADD_AND_MANAGE_TOKENS_ENABLED", + "version": "undefined" + }, + { + "name": "WALLET_CONNECT_BITCOIN_ENABLED", + "version": "undefined" } ] 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..bcbcd299b2 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,10 @@ interface TangemPayApi { @Header("Authorization") authHeader: String, @Body body: WithdrawRequest, ): ApiResponse + + @PATCH("v1/card") + suspend fun updateCardDisplayName( + @Header("Authorization") authHeader: String, + @Body body: UpdateCardDisplayNameRequest, + ): 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/UpdateCardDisplayNameRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt new file mode 100644 index 0000000000..b5c9e74e58 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.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 UpdateCardDisplayNameRequest( + @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/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 38e8982664..906a69fa12 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 @@ -29,6 +29,7 @@ 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?, ) { @JsonClass(generateAdapter = false) enum class Status { 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..49fb34198e --- /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: String, + ) +} \ 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/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/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..d179f07eb3 --- /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.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..bc7825051f --- /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.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 7b1ca88f44..98bfec8c62 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 @@ -43,6 +43,7 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "is_pin_set") val isPinSet: Boolean, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, + @Json(name = "display_name") val displayName: String?, ) : PaymentAccountStatusValueDM @NameLabel("card_issue_failed") 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/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 07bf709771..6582af4d32 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -306,6 +306,7 @@ Stunde Importieren In Arbeit + Unzureichende Mittel Später Mehr erfahren %1$s übrig @@ -421,6 +422,7 @@ Token existiert bereits Dezimalzahl muss eine gültige Ganzzahl sein, bis zu %d Benutzerdefinierte Ableitung(derivation) + Die Funktion \"Dynamische Adressen\" ist aktiviert. Benutzerdefinierte \\"change\\" und \\"index\\" sind nicht verfügbar. E. g. m/00\'/0000\'/0\'/0/0 Benutzerdefinierte Ableitung (Derivation) eingeben Dezimalstellen @@ -473,6 +475,19 @@ Das Senden von Vermögenswerten in anderen Netzwerken führt zu dauerhaftem Verlust. %s Netzwerk Sende Geld nur mit + Dynamische Adressen + Dynamische Adressen ist aktiviert. Die benutzerdefinierten Funktionen \"Ändern\" und \"Index\" sind nicht verfügbar. + Einige Adressen fehlen + Verwenden Sie für jede Transaktion eine neue Adresse, um die Rückverfolgbarkeit zu verringern und den Datenschutz in der Kette zu verbessern. + Verbesserter Datenschutz + Einfacher Geldempfang in UTXO-basierten Netzwerken mit automatischer Adressgenerierung - keine manuelle Adressverwaltung erforderlich. + Nahtloser Empfang + Dynamische Adressen aktivieren + Bei dynamischen Adressen wird jedes Mal eine neue Adresse erstellt, um den Datenschutz zu gewährleisten - Ihr Gesamtguthaben bleibt gleich. + Dynamische Adressen können nicht aktiviert werden, da einige benutzerdefinierte Adressen/Tokens einen geänderten Ableitungspfad verwenden, der die erforderlichen Kriterien nicht erfüllt. + Nicht verfügbar + Wir können im Moment keine Verbindung zum Provider herstellen. Bitte versuchen Sie es später noch einmal. + Der Dienst ist nicht verfügbar. Bitte versuchen Sie es erneut. Beste Gelegenheiten Filter löschen Die Liste ist vorübergehend leer, da sie gerade aktualisiert wird. Schauen Sie in Kürze wieder rein. @@ -749,12 +764,18 @@ Die Daten dieses Abschnitts stammen aus den folgenden Netzwerken: %s Die Daten konnten nicht geladen werden... Keine Daten + **Hinzufügen zu Ihrem Portfolio**, um mit dem Kauf, Tausch oder Erhalt dieses Vermögenswerts zu beginnen + Zum Portfolio hinzufügen Marktimpuls Schnelle Aktionen + Alles löschen Markt durchsuchen + Neueste + In Ihrem Portfolio Ergebnis Token unter 100k USD Marktkapitalisierung anzeigen Token anzeigen + Krypto, Nachrichten und mehr Kein Ergebnis Netzwerk auswählen Wallet auswählen @@ -827,9 +848,11 @@ Position im Krypto-Rating zwischen allen Coins basierend auf der Marktkapitalisierung Marktposition Maximale Versorgung + Zirkulation und maximale Versorgung Die maximale Anzahl von Coins oder Tokens, die jemals für eine bestimmte Kryptowährung existieren können Maximale Versorgung Metriken + Nicht begrenzt Offizielle Links Preisleistung Aufbewahrungsort @@ -1104,10 +1127,14 @@ Einstellungen Du hast keinen Zugriff auf deine Kamera gewährt Kamerazugriff verweigert + Der angeforderte Token wurde Ihrer Wallet nicht hinzugefügt. Bitte fügen Sie ihn hinzu und versuchen Sie es erneut. + Token nicht hinzugefügt Dieser QR-Code konnte leider nicht erkannt werden. Unerkannter QR-Code Dieses Netzwerk wird von keinem der von Ihnen hinzugefügten Token unterstützt. Fügen Sie einen unterstützten Token hinzu, um Krypto zu senden. Keine unterstützten Token gefunden + Dieser QR-Code enthält Parameter, die nicht erkannt werden: %s. Einige Zahlungsdetails können verloren gehen, wenn Sie fortfahren. + Unbekannte Parameter Kein Memo erforderlich %1$s ( %2$s ) im %3$s Netzwerk %1$s im %2$s Netzwerk @@ -1222,6 +1249,10 @@ Memo: %s Ungültiges Memo Abdeckung der Netzgebühren + + %d Token ist mit dieser Adresse nicht kompatibel + %d -Tokens sind mit dieser Adresse nicht kompatibel + Nonce Eindeutige Nummer für jede Transaktion. Verwende diese Nummer, um eine ausstehende Transaktion erneut zu senden oder abzubrechen. Nonce eingeben… @@ -1489,14 +1520,18 @@ Fehler bei der Gebührenschätzung. Bitte sende dein Feedback an den Support. Du wechselst Der Tausch dieser Menge ausgewählter Token hat erhebliche Auswirkungen auf den Preis und verringert dein Ergebnis. + Aufgrund geringer Liquidität erhalten Sie möglicherweise deutlich weniger. Versuchen Sie es mit einem kleineren Betrag oder einem anderen Anbieter. Hoher Einfluss auf den Preis Unzureichende Mittel + Nicht genügend Geldmittel, um diese Transaktion abzuschließen. Verringern Sie den zu erhaltenden Betrag oder fügen Sie weitere Mittel hinzu. Erlaubnis erteilen Tauschen Tauschen... Du erhältst Token auswählen Nicht verfügbar + Nicht genug Liquidität für diesen Handel. Reduzieren Sie den Betrag oder wählen Sie einen anderen Anbieter. + Handel zu groß Wir freuen uns über Ihr Feedback Tangem Pay jetzt in der Beta Karte eingefroren @@ -1567,7 +1602,7 @@ Empfangen ist jetzt nicht verfügbar Aufdecken Details anzeigen - Tausche beliebige Vermögenswerte in Deinem Portfolio gegen eine Karte. + Tausche beliebige Vermögenswerte in Deinem Portfolio für deine Karte. Kartendetails Karte entsperren Komm zurück zur App, falls du es vergisst. @@ -1657,6 +1692,7 @@ Token in %%image%% %1$s Netzwerk Der %1$s (%2$s) Token ist die Hauptwährung im %3$s Netzwerk und kann nicht versteckt werden, solange du andere Token dieses Netzwerks in der Liste aktiv hast. %s kann nicht ausgeblendet werden + N / A QR-Code anzeigen Tausche diesen Token gegen einen anderen zu %1$s Servicegebühren von Februar %2$s-%3$s. Tausche mit Changelly, %s Gebühren @@ -1699,6 +1735,9 @@ Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support. Verwende %s oder scanne eine Karte oder Ring, um Zugriff auf deine Wallet zu erhalten. Verbindung fehlgeschlagen: Diese dApp verwendet Wallet Connect Version 1.0, die nicht unterstützt wird. Bitte stelle sicher, dass die dApp Wallet Connect Version 2.0 unterstützt, um eine erfolgreiche Verbindung herzustellen. + Die vorherige Genehmigung wird widerrufen und eine neue erteilt. Das Netzwerk erhebt eine Token-Genehmigungsgebühr für jede dieser Aktionen. Als Beweis für den Widerruf wird in der Historie eine Transaktion mit einem Betrag von Null angezeigt. + Die Transaktion übersteigt den zuvor genehmigten Betrag.\nErlaubnis zum Fortfahren aktualisieren + Erlaubnis zur Aktualisierung Upgrade auf Hardware-Wallet Bleib auf dem Laufenden mit den neuesten Funktionen und Neuigkeiten Echtzeit-Warnungen für Transaktionen, Umtausch und wichtige Aktualisierungen. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index b257cbe438..970e3f83af 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -161,7 +161,7 @@ Se requiere atención Se produjo un error al procesar tu código promocional. Inténtalo de nuevo más tarde. Error de activación - Tu código promocional se activó correctamente. Una recompensa se acreditará en tu cuenta dentro de 14 días. + Su código promocional se activó correctamente. Se acreditará la recompensa en su cuenta dentro de 14 días. Código promocional activado Este código promocional ya ha sido utilizado y no puede activarse de nuevo. Código no disponible @@ -306,6 +306,7 @@ hora Importe En progreso + Fondos insuficientes Más tarde Más información %1$s quedan @@ -326,6 +327,7 @@ NFT No Ninguna dirección + Sin resultados No agregadas No disponible Ahora no @@ -384,6 +386,7 @@ A A %s Hoy + Token para enviar %d token %d tokens @@ -396,6 +399,7 @@ Entiendo Entiendo, continuar Hubo un error. Por favor inténtelo de nuevo. + Desbloquear Inaccesible Termine el staking Debido a limitaciones sobre %1$s, solo %2$d UTXO pueden caber en una sola transacción. Esto significa que solo puedes enviar %3$s o menos. Debe reducir la cantidad. @@ -405,7 +409,7 @@ semana con - Modo de rendimiento + Modo de Rendimiento Dirección de contrato copiada Redes disponibles La derivación de su token coincide con la derivación de %1$s. Su token se añadirá a esta cuenta. @@ -418,6 +422,7 @@ Este token ya existe Los decimales deben ser un número entero válido, hasta %d Derivación personalizada + La opción Direcciones dinámicas está habilitada. Las opciones personalizadas \\"cambiar\\" e \\"índice\\" no están disponibles. Por ejemplo m/00\'/0000\'/0\'/0/0 Introduzca una derivación personalizada Decimales @@ -460,7 +465,7 @@ Firmado Enviar comentarios Detalles - Puedes tener solo una billetera móvil a la vez. Puede actualizarse a una billetera fría Tangem o usarse junto con una nueva billetera fría. + Puede tener solo una billetera móvil. Puede actualizarse a una billetera fría Tangem o añadir una billetera de hardware nueva. Compruebe su conexión a internet o cambia a una red diferente Condiciones de uso Dirección por defecto @@ -470,6 +475,19 @@ Enviar activos a otras redes resultará en una pérdida permanente. Red de %s Envíe fondos utilizando solo + Direcciones dinámicas + La opción Direcciones dinámicas está habilitada. Las opciones personalizadas \"cambiar\" e \"índice\" no están disponibles. + Direcciones dinámicas habilitadas + Utilice una nueva dirección para cada transacción para reducir la trazabilidad y mejorar la privacidad on-chain. + Privacidad mejorada + Reciba fondos fácilmente en redes basadas en UTXO con generación automática de direcciones, sin necesidad de gestión manual de direcciones. + Recepción sin problemas + Habilitar direcciones dinámicas + Las direcciones dinámicas crean una nueva cada vez para mayor privacidad - su saldo total no varía. + Las direcciones dinámicas no pueden activarse porque algunas direcciones/tokens personalizadas utilizan una ruta de derivación modificada, que no cumple los criterios requeridos. + Direcciones dinámicas no disponibles + No podemos conectar con el proveedor en este momento. Vuelva a intentarlo más tarde. + Servicio no disponible. Por favor, inténtelo de nuevo. Las mejores oportunidades Limpiar filtro La lista está temporalmente vacía porque se está actualizando. Vuelva a consultarla en un momento. @@ -478,9 +496,9 @@ Filtrar por Mis redes Redes - Mayormente usado + Popular Sin resultados - Ganar + Modo Staking & Rendimiento Hola equipo de soporte, he encontrado un error con el código: %s Error de WalletConnect Ha usado una tarjeta de otra billetera. Toque la tarjeta asociada con esta billetera @@ -653,7 +671,7 @@ ¿Está seguro que desea hacer esto? Olvidar billetera Ir a copia de seguridad - Ver copia de seguridad + Ver frase de recuperación Olvidar la billetera Olvidar de todos modos Esta billetera tiene una copia de seguridad. Asegúrese de poder recuperarla antes de olvidarla. @@ -689,6 +707,9 @@ Acerque para escanear Toque para firmar Coloque la tarjeta o el anillo + Su billetera está sincronizada y lista. \n Faltan algunos tokens? + Billetera importada correctamente + Restaurando %d%% Ha actualizado la biometría, escanee su tarjeta o anillo para entrar Su saldo debe ser mayor que el valor de la tarifa para hacer una transferencia Saldo insuficiente @@ -700,6 +721,7 @@ Nivel de Mana Para hacer un seguimiento de sus criptomonedas y transacciones, agregue tokens Gestionar tokens + Escanee el código QR para enviar fondos o conectarse a una aplicación Para acceder a todas las redes necesita escanear la tarjeta Escanee su tarjeta o anillo Disfrute de %1$s comisiones de servicio en los swaps a través de Changelly a partir de febrero %2$s-%3$s @@ -734,15 +756,22 @@ APY %s Mi portafolio Mercado - Gane con Tangem + Modo Staking & Rendimiento Para generar direcciones para las redes seleccionadas, debe escanear su tarjeta Tangem Para agregar tokens, abra esta página o pulse sobre la barra de búsqueda + Deslice el dedo hacia arriba para explorar el mercado + Descubre nuevas gemas ocultas Los datos de este apartado proceden de las siguientes redes: %s No se pueden cargar los datos… Sin datos + **Añadir a su portafolio** para empezar a comprar, intercambiar o recibir este activo + En su portafolio Análisis del Mercado Acciones rápidas + Borrar todo Buscar tokens + Recientes + En su portafolio Resultado Ver tokens con marketcap inferior a 100.000$ Mostrar tokens @@ -799,6 +828,10 @@ Titulares El cambio en el número de poseedores de tokens dentro de un período de tiempo específico Titulares + D + M + S + A Ideas Enlaces Liquidez @@ -814,9 +847,11 @@ Posición en la clasificación de criptomonedas entre todas las monedas según la capitalización de mercado Evaluación de mercado Suministro máximo + Suministro circulante y máximo La cantidad máxima de monedas o tokens que pueden existir para una criptomoneda en particular Suministro máximo Métrica + Sin limitación Enlaces oficiales Rendimiento de precios Repositorio @@ -826,9 +861,11 @@ Suministro total La cantidad máxima de monedas o tokens que pueden existir para una criptomoneda en particular Suministro total + 24h Volumen de operaciones (24 horas) La cantidad total de una criptomoneda que se ha negociado en las últimas 24 horas, lo que indica el nivel de actividad y liquidez en el mercado. Volumen de operaciones (24 horas) + %s en total Volumen Tire hacia arriba o toque la barra de búsqueda para agregar tokens directamente desde el mercado Agregar tokens @@ -852,6 +889,7 @@ Tokens relacionados Noticias relacionadas Manténgase informado + Puntuación de tendencia NFC no está disponible en su dispositivo Acerca de NFT Activo NFT @@ -909,8 +947,8 @@ Obtén $10 en BTC con cada billetera \n ¡Date prisa! Black Friday: hasta 30% DESCUENTO Vamos - Crea el par perfecto de packs de Tangem. Por tiempo limitado. - 1+1: Compra una billetera y obtén un 50% dto. en la segunda + ¡Tiempo limitado! + 1+1: Compra una billetera y obtenga un 50% dto. en la segunda Únase ahora Comparta su código y gane 5 USDT por venta. Su amigo obtiene un 10% de descuento. ¡Obtenga RECOMPENSAS por cada amigo! @@ -1088,6 +1126,14 @@ Ajustes No ha dado acceso a su cámara Acceso a la cámara denegado + El token solicitado no se ha añadido a su billetera. Por favor, añádalo e inténtelo de nuevo. + Token no añadido + Lo sentimos, no se ha podido reconocer este código QR. + Código QR no reconocido + Esta red no es compatible con ninguno de sus tokens añadidos. Añada un token compatible para enviar cripto. + No se encontraron tokens compatibles + Este código QR contiene parámetros que no son reconocidos: %s. Si continúa, es posible que se pierdan algunos detalles de pago. + Parámetros desconocidos No se requiere nota %1$s (%2$s) en la red %3$s %1$s en la red %2$s @@ -1164,7 +1210,7 @@ Ha especificado una comisión inferior a la cantidad recomendada, lo que podría provocar un retraso en su transacción. ¿Continuar? Razón: %1$s\nCódigo: %2$s La transacción está incompleta - Convertir a otro token + Cambiar a otro token o red Montante Se enviará al destinatario Puede establecer su tarifa de transacción ajustando el valor en el campo Satoshi por vByte. @@ -1202,6 +1248,10 @@ Memo: %s Memo no válido Cobertura de tarifa de red + + El token %d no es compatible con esta dirección + Los tokens %d no son compatibles con esta dirección + Nonce Número único para cada transacción. Utilícelo para reenviar o cancelar una transacción pendiente. Introduzca nonce... @@ -1229,6 +1279,11 @@ Limitación de transacciones Opcional Por favor, alinee su código QR con el cuadrado para escanearlo. Asegúrese de escanear la dirección de la red %s. + Al utilizar un tipo de cambio fijo, el importe que recibe queda garantizado en el momento de la operación. Esto le protege de las fluctuaciones de precio durante la transacción. + Tasa fija + Un tipo variable significa que la cantidad final que recibe puede variar ligeramente en función de las condiciones del mercado entre el momento en que inicia y finaliza el swap. + Tasa flotante + La tasa es fija Reciente Destinatario No es una dirección válida @@ -1272,6 +1327,7 @@ El destinatario recibe %s ¿Seguro que desea cancelar la conversión? Se borrarán sus datos anteriores. Eliminar conversión + Algo salió mal. Inténtelo de nuevo. Enviar con swap Transacción enviada Escanee la tarjeta/anillo que quiere configurar @@ -1463,14 +1519,18 @@ Error en la estimación de la tarifa. Envíe sus comentarios al servicio de asistencia. Usted intercambia Hacer un intercambio de esta cantidad del token seleccionado causará un impacto significativo en el precio y reducirá su resultado. + Es posible que reciba una cantidad significativamente menor debido a la baja liquidez. Pruebe con una cantidad menor o con otro proveedor. Alto impacto en los precios Fondos insuficientes + No hay fondos suficientes para completar esta transacción. Reduzca el importe a recibir o añada más fondos. Dar autorización Intercambiar Intercambiando... Usted recibe Elige token no disponible + No hay liquidez suficiente para esta operación. Reduzca el importe o elija otro proveedor. + Operación demasiado grande Nos encantaría recibir tus comentarios Tangem Pay ya está en beta Tarjeta congelada @@ -1509,6 +1569,7 @@ Ocultar verificación de la pantalla Agregar fondos Opciones de recarga + Añadir a Google Wallet Número de tarjeta Modificar PIN La tarjeta está completamente lista para pagos. @@ -1596,7 +1657,7 @@ Sesión expirada Restablecer acceso Usa USDC para pagos cotidianos - Tangem Pay temporalmente no disponible + Tangem Pay no está disponible temporalmente. Tangem Pay Haga clic en el botón de abajo para restaurar el acceso Tu saldo USDC Polygon on-chain difiere de tu saldo de tarjeta y se actualiza dentro de 2 días hábiles después de una compra. Los fondos de compras reembolsadas no se devolverán a tu saldo on-chain ni estarán disponibles para retiro, pero permanecerán en tu saldo de tarjeta para compras. @@ -1630,6 +1691,7 @@ Token en la red %%image%% %1$s El token %1$s (%2$s) es la moneda principal en la red %3$s y no se puede ocultar mientras tengas otros tokens de esta red en la lista No se puede ocultar %s + N/A Mostrar código QR Cambie este token por otro por una tarifa de servicio de %1$s del %2$s al %3$s de febrero. Intercambie con Changelly, %s comisiones @@ -1672,6 +1734,9 @@ Hemos encontrado un error. Código de error: %s. Póngase en contacto con nuestro servicio de soporte. Use %s o escanee una tarjeta/anillo para tener acceso a su billetera Error de conexión: Esta dApp utiliza la versión 1.0 de Wallet Connect, que no es compatible. Asegúrese de que la dApp sea compatible con la versión 2.0 de Wallet Connect para conectarse correctamente. + Se revocará el permiso anterior y se emitirá uno nuevo. La red cobrará una tarifa de aprobación de tokens por cada una de estas acciones. Verá una transacción de importe cero en el historial como comprobante de la revocación. + La transacción excede el límite de permisos otorgados previamente.\nActualice los permisos para continuar. + Actualizar permisos Actualización a billetera de hardware Manténgase actualizado con las últimas funciones y noticias Alertas en tiempo real de transacciones, intercambios y actualizaciones críticas. @@ -1842,7 +1907,7 @@ Este código de acceso protege su billetera y se utiliza para iniciar sesión y firmar transacciones. Establecer/Cambiar código de acceso Cambiar código de acceso - Manténgase informado sobre las transacciones entrantes de la billetera y las actualizaciones de Tangem. + Reciba notificaciones push para las transacciones entrantes en su billetera. Es posible que las notificaciones push no funcionen actualmente en dispositivos Huawei. Estamos trabajando activamente en una solución y la publicaremos en una próxima actualización. ¡Gracias por su comprensión! Notificaciones de transacciones Establecer código de acceso @@ -1870,7 +1935,7 @@ Según la documentación oficial de Clore, todas las monedas recibidas antes del 21 de diciembre serán migradas a Clore (token ERC-20); las monedas recibidas después de esa fecha no lo serán. Se está desarrollando una solución de transferencia — mantente atento. Mensaje Abrir el portal de reclamaciones - Para mantener el acceso a sus fondos, comience la migración de acuerdo con las pautas oficiales de Clore. + Para continuar utilizando sus tokens de Clore, debe completar la migración de tokens de acuerdo con la información del Portal de Reclamación. Migración de la red Clore Firmar Firma @@ -1925,7 +1990,7 @@ La red no está disponible actualmente. Por favor, inténtalo de nuevo más tarde. La red no está disponible Recargue su billetera - Su billetera no ha sido respaldado. Realice este procedimiento para proteger sus activos ahora. + Su billetera no tiene aún una copia de seguridad. Hágala ahora para proteger sus activos. Falta backup Esta tarjeta se ha utilizado previamente para transacciones. Si la recibió de una fuente no confiable, considere retirar todos los fondos. Si es su tarjeta, no se requiere ninguna acción. La tarjeta ya ha firmado transacciones @@ -2099,7 +2164,7 @@ La tasa de red es actualmente demasiado alta para ejecutar préstamos. Los fondos se suministrarán una vez que baje a %1$s o menos. Mis fondos Su %1$s está ahora depositado en Aave y devenga intereses. Tiene un token%2$s, que representa su saldo y crece con el tiempo. Cuando deposite más fondos, estos se suministrarán a Aave para ganar intereses, menos una comisión por transacción. - Modo de rendimiento + Modo Rendimiento Ganancias totales Transferencias a Aave Explore Aave @@ -2111,7 +2176,7 @@ Tarifa máxima El monto mínimo se calcula en función de la comisión de la red actual para que no supere el 4%% del importe de la recarga, lo que hace que el mínimo sea %1$s (%2$s). Recarga mínima - Política de tarifas + Política de tarifas por recarga Tangem también cobra una comisión de servicio del 15% sobre el rendimiento obtenido. Sus fondos se suministrarán automáticamente a Aave una vez que las comisiones de red sean más bajas o su saldo alcance el importe mínimo requerido. Las comisiones son más altas de lo habitual debido a la alta actividad del mercado. Puede continuar ahora o volver a consultar más tarde cuando las comisiones sean más bajas. @@ -2131,7 +2196,7 @@ Aave es un protocolo on-chain para crear mercados de liquidez no custodiales y ganar intereses a tasa variable. Descentralizado y autocustodiado Al utilizar este servicio, usted acepta que el proveedor\n%1$s y %2$s - Conectar Aave + Conectar con Aave Consiga un %1$s%% APY\nen su saldo Aave %1$s%% - Tipo de interés variable Tipo de interés variable @@ -2144,7 +2209,7 @@ Cuando recargue, sus fondos se enviarán automáticamente a Aave para comenzar a ganar intereses. %s se deducirá para cubrir la tarifa de transacción. Suministro de activos Su %s se suministrará a Aave, pero seguirá siendo gestionable. - Ver política de tarifas + Ver política de tarifas por recarga Sus próximas recargas se suministrarán automáticamente a Aave. Todos tus futuros depósitos %1$s se suministrarán automáticamente a Aave. Activo @@ -2158,8 +2223,8 @@ APY Los intereses se devengan automáticamente Los intereses se devengan automáticamente - Modo de rendimiento - Procesando su depósito + Modo Rendimiento + Activando Modo Rendimiento Modo Rendimiento Implementación del contrato del Modo Rendimiento Modo Rendimiento activado diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 9075ee073a..5cfb59bdbc 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1086,6 +1086,14 @@ Paramètres Vous n\'avez pas donné accès à votre caméra Accès à la caméra refusé + Le jeton demandé n\'est pas ajouté à votre portefeuille. Veuillez l\'ajouter, puis réessayez. + Le jeton n\'a pas été ajouté + Désolé, ce code QR n\'a pas pu être reconnu. + Code QR non reconnu + Ce réseau n\'est pris en charge par aucun des jetons que vous avez ajoutés. Ajoutez un jeton pris en charge pour envoyer des cryptomonnaies. + Aucun jeton pris en charge n\'a été trouvé + Ce code QR contient des paramètres non reconnus : %s. Si vous continuez, certaines informations de paiement risquent d\'être perdues. + Paramètres inconnus Aucun mémo requis %1$s (%2$s) sur le réseau %3$s %1$s sur le réseau %2$s diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 404b67969c..c2dfddaf6f 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -415,6 +415,7 @@ トークンはすでに存在します 小数は%dまでの有効な整数である必要があります カスタム派生パス + 動的アドレスが有効です。カスタムの「change」と「index」は利用できません。 例:m/00\'/0000\'/0\'/0/0 カスタム派生パスを入力 小数 @@ -756,11 +757,13 @@ このセクションのデータは、次のネットワークから取得されています: %s データを読み込めません… データなし + **ポートフォリオに追加して**、この資産の買付・交換・受け取りを始めましょう + ポートフォリオ内 マーケット動向 クイックアクション すべてクリア トークンを探す - 最近 + 最近の検索 ポートフォリオ内 結果 時価総額10万ドル以下のトークンを見る @@ -1712,6 +1715,9 @@ エラーが発生しました。エラー コード:%s。サポートにお問い合わせください。 %sを使用するか、カード / リングをスキャンしてウォレットにアクセスしてください 接続に失敗しました:このdAppは、サポートされていないWallet Connectバージョン1.0を使用しています。正常に接続するには、dAppがWallet Connectバージョン2.0をサポートしていることを確認してください。 + 以前の承認は取り消され、新しい承認が発行されます。これらの各操作には、ネットワークによるトークン承認手数料がかかります。履歴には、取り消しの証拠として金額0の取引が表示されます。 + 取引金額が、以前に許可された承認額を超えています。\n続行するには、承認内容を更新してください。 + 承認を更新 ハードウェアウォレットにアップグレード 最新の機能とニュースをお届けします 取引・スワップ・重要な更新に関するリアルタイム通知。 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 94c47b4783..e011607a12 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -80,6 +80,10 @@ Adicionar tokens Escolha o token que deseja receber. Escolha o token que deseja trocar. + Adicione ao seu portfólio + Adicionar tokens + Classificação e agrupamento + Organizar tokens Escolha a rede Adicionar token personalizado Gerenciar tokens @@ -422,6 +426,7 @@ O token já existe. O valor decimal deve ser um número inteiro válido, até um limite de %d Derivação personalizada + O recurso de Endereços Dinâmicos está ativado. As opções personalizadas \"alterar\" e \"indexar\" não estão disponíveis. Exemplo: m/00\'/0000\'/0\'/0/0 Insira uma derivação personalizada Decimais @@ -474,6 +479,19 @@ O envio de ativos por outras redes resultará em perda permanente. %s rede Envie fundos usando apenas + Endereços dinâmicos + O recurso de Endereços Dinâmicos está ativado. As opções personalizadas \"alterar\" e \"indexar\" não estão disponíveis. + Endereços dinâmicos ativados + Use um novo endereço para cada transação para reduzir a rastreabilidade e melhorar a privacidade na blockchain. + Privacidade aprimorada + Receba fundos facilmente em redes baseadas em UTXO com geração automática de endereços — sem necessidade de gerenciamento manual de endereços. + Recebimento perfeito + Habilitar endereços dinâmicos + Endereços dinâmicos criam um novo a cada vez para maior privacidade — seu saldo total permanece o mesmo. + Não é possível ativar os endereços dinâmicos porque alguns endereços/tokens personalizados usam um caminho de derivação modificado, que não atende aos critérios necessários. + Endereços dinâmicos indisponíveis + Não foi possível conectar-se ao provedor neste momento. Tente novamente mais tarde. + Serviço indisponível. Por favor, tente novamente. Melhores oportunidades Limpar filtro A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco. @@ -570,6 +588,8 @@ Escolha qual token usar para pagar a taxa de rede. %s Escolha o token Mercado e Notícias + Mostrar tudo + Mostrar menos Em alta agora As informações a seguir são opcionais. Você pode apagá-las se não quiser compartilhá-las. Diga-nos quais funções estão faltando e tentaremos ajudá-lo. @@ -705,6 +725,7 @@ Limite de mana A rede Koinos exige Mana para o pagamento das taxas de rede. Você tem %1$s/%2$s Mana Nível de mana + Adicionar e gerenciar Para começar a rastrear seus criptoativos e transações, adicione tokens. Gerenciar tokens Leia o código QR para enviar fundos ou conectar-se a um aplicativo @@ -750,9 +771,14 @@ Os dados desta seção são provenientes das seguintes redes: %s Não foi possível carregar os dados… Sem dados + **Adicione ao seu portfólio** para começar a comprar, trocar ou receber este ativo. + Em seu portfólio Pulso do mercado Ações rápidas + Limpar tudo Pesquisar tokens + Recentes + Em seu portfólio Resultado Veja tokens com capitalização de mercado inferior a 100 mil dólares. Mostrar tokens @@ -1114,6 +1140,8 @@ Código QR não reconhecido Esta rede não é compatível com nenhum dos tokens adicionados. Adicione um token compatível para enviar criptomoedas. Nenhum token compatível encontrado + Este código QR contém parâmetros que não são reconhecidos: %sAlgumas informações de pagamento podem ser perdidas se você continuar. + Parâmetros desconhecidos Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede @@ -1499,6 +1527,7 @@ Erro na estimativa de custos. Por favor, envie seu feedback para o suporte. Você troca Trocar essa quantidade de tokens selecionados causará um impacto significativo no preço e reduzirá seu resultado. + Você pode receber um valor significativamente menor devido à baixa liquidez. Tente um valor menor ou outro provedor. Alto impacto nos preços Fundos insuficientes Não há fundos suficientes para concluir esta transação. Reduza o valor a receber ou adicione mais fundos. @@ -1508,8 +1537,11 @@ Você recebe Escolha o token não disponível + Liquidez insuficiente para esta transação. \n o valor ou escolha outro provedor. + Negociação em grande escala Teremos todo o prazer em receber seu feedback. O Tangem Pay agora está em versão beta. + Não foi possível renomear o cartão. Cartão bloqueado Pagamento com cartão Depósito @@ -1543,7 +1575,9 @@ Seu cartão foi desbloqueado. Retirada Não é possível usar em dispositivos com root. + Saldo disponível Ocultar KYC da tela principal + Cartão Tangem Pay 1 Adicionar fundos Opções de recarga Adicionar ao Google Wallet @@ -1576,10 +1610,13 @@ Compartilhe seu endereço ou mostre o código QR. Problemas técnicos detectados. Tente novamente mais tarde ou entre em contato com o suporte. Receber indisponível agora + Somente letras e números são permitidos. + Caracteres inválidos Revelar Mostrar detalhes Troque qualquer ativo da sua carteira por um cartão. Detalhes do cartão + Por favor, tente novamente mais tarde. Descongelar cartão Volte ao aplicativo se você se esquecer. Seu código PIN @@ -1587,12 +1624,16 @@ Retirada indisponível agora Você não pode iniciar uma troca ou um novo saque até que o atual seja concluído. Retirada em andamento + Configurações do cartão Alterar código PIN Volte ao aplicativo se você se esquecer. + Cartão digital Entendo que perderei completamente o acesso ao meu cartão Tangem Pay e a todos os fundos nele contidos, sem possibilidade de recuperação. Falha na emissão do cartão Ocorreu um erro técnico. Tente novamente clicando no botão abaixo. Ocorreu um erro técnico. Por favor, entre em contato com o suporte. + O recurso estará disponível em breve. + Você poderá emitir cartões adicionais para sua conta de pagamento. Obtenha seu cartão virtual Visa Tangem grátis. Obtenha o Tangem Pay Acesse o Suporte @@ -1624,6 +1665,7 @@ Uma conta de pagamento separada será criada sem divulgar seus endereços e bens. Privacidade incomparável Obtenha seu cartão Tangem Pay gratuito em minutos. + Suporte de pagamento Conta de pagamento A conta de pagamento não está sincronizada. PIN inválido: evite sequências ou repetições. @@ -1636,6 +1678,7 @@ Use USDC para pagamentos do dia a dia. O serviço Tangem Pay está temporariamente inacessível. Tangem Pay + USDC na rede Polygon Clique no botão abaixo para restaurar o acesso. O saldo do seu Polygon em USDC na blockchain é diferente do saldo do seu cartão e é atualizado em até 2 dias úteis após uma compra. Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras. Observe @@ -1666,6 +1709,10 @@ Serviço de staking %1$s token em %%imagem%% %2$s rede Token em %%imagem%% %1$s rede + %s rede + %1$s em %2$s rede + %1$s em %%imagem%% %2$s + %1$s em %2$s %%imagem%% O %1$s (%2$sO token ) é a principal moeda da plataforma. %3$s rede e não pode ser ocultada enquanto você tiver outros tokens dessa rede na lista Não foi possível ocultar %s N/A @@ -1711,6 +1758,9 @@ Ocorreu um erro. Código do erro: %sPor favor, entre em contato com nosso suporte. Usar %s ou escaneie um cartão/anel para acessar sua carteira. Falha na conexão: Este aplicativo descentralizado (dApp) usa a versão 1.0 do Wallet Connect, que não é compatível. Certifique-se de que o dApp seja compatível com a versão 2.0 do Wallet Connect para que a conexão seja estabelecida com sucesso. + A permissão anterior será revogada e uma nova será emitida. A rede cobrará uma taxa de aprovação de tokens para cada uma dessas ações. Você verá uma transação de valor zero no histórico como comprovante de revogação. + A transação excede o limite de permissão concedido anteriormente.\nAtualize a permissão para prosseguir. + Atualizar permissão Faça upgrade para uma carteira de hardware. Fique por dentro das últimas novidades e recursos. Alertas em tempo real para transações, câmbio e atualizações críticas. 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 8df7a978d6..ffcd7afc54 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1124,6 +1124,14 @@ Налаштування Ви не надали доступ до своєї камери Доступ до камери заборонено + Запитуваний токен не додано до вашого гаманця. Будь ласка, додайте його та спробуйте ще раз. + Токен не додано + На жаль, цей QR-код не вдалося розпізнати. + Нерозпізнаний QR-код + Ця мережа не підтримується жодним із ваших доданих токенів. Додайте підтримуваний токен, щоб надіслати криптовалюту. + Підтримуваних токенів не знайдено + Цей QR-код містить нерозпізнані параметри: %s. Деякі деталі платежу можуть бути втрачені, якщо ви продовжите. + Невідомі параметри Memo не вимагається %1$s (%2$s) у мережі %3$s %1$s у мережі %2$s 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 9a07214403..13a1972ba4 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1,19 +1,650 @@ - 添加代幣 - 沒有結果 - 要發送的代幣 - 解鎖 - 您的錢包已同步並準備就緒。\n缺少某些代幣? - 錢包導入成功 - 恢復%d%% + 更改代码 + 您的访问代码可解锁和保护您的钱包访问权限 + 无论如何都要用 + 这个访问码很容易猜到。 + 您不能创建超过 %1$s 账户。存档一个账户以添加新账户。 + 无法添加新帐户 + 账户已存档 + 已存档账户 + 您即将恢复 \"%1$s\"。 + 恢复账户 + 您已达到20个活跃账户的上限。请归档一个账户以恢复此账户。 + 无法恢复帐户 + 我们无法存档账户。请稍后再试。 + 此账户正在参与推荐计划。 + 此帐户无法存档 + 我们无法创建账户。请稍后再试。 + 账户已创建 + 存档... + 无法编辑帐户 + 账户已保存 + 奖励账户 + 已存在使用此名称的帐户。请选择其他名称。 + 帐户名已被使用 + 请稍后再试。如果问题仍然存在,请联系客服,我们将协助您解决问题。 + %1$s 在 %2$s + 主账户 + 您已超过 %1$s 活动账户的限制。存档一个以恢复 + 无法恢复帐户 + 账户已恢复 + 长按账户可重新排序 + 一些自定义代币将自动从 \"%1$s\" 移至 \"%2$s\",因为它们的派生词属于该账户。 + 部分自定义代币将自动移动 + 生物识别出现问题。请尝试重置设备上的生物识别技术或联系技术支持。 + 验证错误 + 设置访问码以启用生物识别功能 + 稍后会要求您提供访问代码以进行安全存储。 + 这将删除所有已保存的钱包访问码。您需要重新输入访问码才能使用钱包。 + 您的设备已关闭生物识别功能,因此无法使用此功能解锁钱包。请在设备设置中启用生物识别功能,即可再次使用此方法。 + 生物识别认证已禁用 + 您的生物识别尝试次数已达上限。请使用卡片/指环解锁钱包,或输入您的访问码。 + 生物识别认证已锁定 + 生物识别登录暂时锁定。请30秒后重试,或轻触设备或输入访问码解锁您的钱包。 + 您设备上的生物识别信息已更新。请选择您的钱包并输入其访问码以重新启用生物识别登录。 + 需要注意 + 处理您的优惠码时出错,请稍后再试。 + 激活错误 + 您的优惠码已成功激活。奖励将在14天内计入您的比特币账户。 + 促销代码已激活 + 此优惠码已被使用,无法再次激活。 + 代码不可用 + 此优惠码无效,无法激活。 + 无效代码 + 您需要提供比特币地址才能领取奖励。请将比特币地址添加到您的钱包,然后重试激活。 + 需要比特币地址 + 请重置下一个设备以继续。 + 钱包重置 + 所有 Tangem 设备均已重置。您现在可以继续升级钱包。 + 再次升级 + 重置完成 + 我们建议在此钱包中完成所有 Tangem 设备的重置过程。 + 部分 Tangem 设备仍需重置。 + 账户 + 账户 + 激活 + 添加 + 添加代币 + 已添加 + + 选择账户 + 联系客服 + 充值网络费 + 忘记 + + 从 %s + 开始 + 获取代币 + 保持到 %s + 余额不足 + 了解更多 + 传统比特币 + 锁定的钱包 + + %d网络 + + 新地址 + 新闻 + 无结果 + 不可用 + 或者 + 推荐 + 重置 + 出问题了 + Tangem + 点击并按住 + + 到 %s + 要发送的代币 + 无法加载数据…… + 我明白,请继续 + 解锁 + 钱包 + 收益模式 + 您的代币派生信息与 %1$s的派生信息一致。 您的代币将被添加到该账户。 + 派生属于另一个账户。 + 代币已添加到 %1$s 帐户 + 动态地址已启用。自定义\“更改”\和\“索引”\不可用。 + 升级到硬件钱包 + 您只能拥有一个手机钱包。将其升级为 Tangem 硬件钱包,或添加一个新的硬件钱包。 + 默认地址 + 传统 %s 地址 + %s 地址 + 动态地址 + 动态地址已启用。自定义“更改”和“索引”功能不可用。 + 已启用动态地址 + 每次交易都使用一个新地址,以减少可追溯性并提高链上隐私性。 + 增强隐私 + 通过自动生成地址,轻松在基于UTXO的网络中接收资金——无需手动管理地址。 + 无缝接收 + 启用动态地址 + 动态地址每次都会创建一个新地址,以增强隐私保护——您的总余额保持不变。 + 无法启用动态地址,因为某些自定义地址/代币使用的是修改后的派生路径,不符合所需的标准。 + 动态地址不可用 + 我们目前无法连接到服务提供商,请稍后再试。 + 服务不可用,请稍后再试。 + 最佳机会 + 清除筛选 + 列表正在刷新,暂时为空。请稍后再查看。 + 所有网络 + 所有类型 + 筛选方式 + 我的网络 + 网络 + 热门 + 无结果 + 质押与收益模式 + 将您投资组合中的任何资产交换为该代币 + 有竞争力的费率 + 速度越快,确认速度越快,但网络费用也越高。 %s + 选择速度 + 选择用于支付网络费用的代币。 %s + 选择代币 + 市场与新闻 + Tangem AI + 当下热门 + 备份问题 + 资金不足 + 转账费 + 地址直接在您的 Tangem 硬件钱包上生成,随时可用,并受到全面保护。 + 新地址 + 添加 Tangem 钱包 + 您的私钥将直接在 Tangem 卡内生成,并且永远不会离开该卡。 + 密钥生成 + 所有加密操作都在安全芯片内部进行,该芯片经过认证,可防止克隆和物理篡改。 + 硬件级安全 + 您是否允许“Tangem”使用生物识别认证来确认您的身份并打开应用程序? + 让您的加密货币安全离线存储。轻薄如信用卡,安全胜过银行金库。 + 创建一个安全的钱包并转移资金,以加强保护。 + 创建新钱包 + 使用卓越的 Tangem 硬件钱包,提升您的安全保障。 + 硬件钱包 + 将您当前的手机钱包转换为 Tangem 冷钱包。 + 升级当前钱包 + 先完成备份 + 其他方法 + 将您的恢复短语保存在安全的地方,保持其私密性以保护您的资金,并设置访问密码以提高安全性。 + 要使用访问码保护您的钱包,请完成备份过程。 + 要升级到硬件钱包,请完成备份过程。 + 将您的手机钱包升级到 Tangem 硬件钱包,享受最高级别的安全性。导入您的钱包或将资金转移到新钱包。 + 升级到冷钱包 + 随时安全地将手机钱包转移到 Tangem 卡片或指环上。 + 升级到硬件钱包 + 导入现有钱包 + 此恢复短语已导入 + 忘记钱包 + 此钱包将从您的设备中永久移除。 + 你确定要这么做吗? + 忘记钱包 + 转到备份 + 查看恢复短语 + 忘记钱包 + 确定忘记 + 这个钱包有备份。在忘记钱包之前,请确保你能找回备份。 + 如果您忘记了这个钱包而又没有备份,您将永远无法使用您的资金。 + 你确定要忘记这个钱包吗? + 我知道,如果我在移除钱包之前没有备份钱包,我将无法访问钱包。 + 我知道移除钱包并不会将其删除,只会将其从我的设备中移除。 + 升级 + 当您将手机钱包升级为 Tangem 卡或指环时,您的所有地址和余额都可以完全访问。 + 相同地址 + 无法升级。此设备上已有一个钱包。 + 请换一台设备。这台设备不能用于升级。 + 操作过程中发生错误。 + 在此过程中,您的资金将保持安全且完全可用。 + 获得资金 + 升级后,您的移动钱包将从应用程序中移除并存储在您的 Tangem 硬件钱包中。您的助记词将保留在您身边。 + 一般安全 + 私钥将从应用程序转移到您的 Tangem 硬件钱包。 + 私钥迁移 + 扫描设备 + 开始升级 + 您即将升级到我们的硬件钱包。它会将您的资产安全地存储在冷存储中。 + Tangem钱包 + 升级到我们的硬件钱包 + 使用 Tangem 的顶级硬件钱包,确保您的加密货币安全。 + 将您的钱包升级到硬件安全级别 + 您的钱包已同步并准备就绪。\n缺少某些代币? + 钱包已成功导入 + 恢复 %d%% + 扫描二维码即可发送资金或连接到应用程序 + 关于硬币 + 质押与收益模式 + 向上轻扫,探索市场 + 寻找新的隐藏宝石 + **添加到您的投资组合**,即可开始购买、交换或接收此资产 + 在您的投资组合中 + 市场脉搏 + 全部清除 + 最近的 + 在您的投资组合中 + 加密货币、新闻及更多 + 收益模式 + 代币已添加 - + - 使用固定利率時,您在置換交易時收到的金額將被鎖定,這可以保護您免受交易過程中價格波動的影響。 + 循环和最大供应 + 无限制 + 24小时 + %s 总共 + 添加更多代币 + 增强资产性能,并使其能够即时访问。 %s + 激活收益模式 + 创建手机钱包前必须更新至 %1$s + 手机钱包需要 %1$s 或更高版本 + 所有新闻 + 喜欢 + + 小时之前 + + + 分钟之前 + + 快速回顾 + 新闻 + 相关代币 + 相关新闻 + 随时了解最新动态 + 趋势得分 + 每个钱包均可获得价值 10 美元的 BTC \n快来领取! + 黑色星期五:最多可省 30 美元% + 我们出发吧 + 限时优惠! + 1+1:买一个钱包,优惠 50% + 购买加密货币 + 通过 SEPA 转账购买加密货币时,可享受 ** 0% 费用**。 + 使用 SEPA 购买加密货币 + 加入候补名单,即可获得一张与众不同的支付卡。 + Tangem Visa卡 + 条款和条件 + 存款 100 美元以上,持有 30 天,即可获得 10 美元 + 加入 \"收益模式 \"活动 + 生物识别 + 您只能拥有一个手机钱包。将其升级为 Tangem 硬件钱包,或添加一个新的硬件钱包。 + 所有优惠 + 此交易已处理完毕,无需进一步操作。 + 获得最佳利率... + 即时 + 服务由外部供应商提供。\nTangem对此不承担任何责任。 + 最快处理 + 付款方式 + + 提供者 + + 提供商 + 最近使用过 + 推荐 + + 至多%d天 + + %s 分钟 + 可从 + 最高可提供 + 你得到 + 服务由外部供应商提供。\nTangem 不承担任何责任。 + 至多 + 通过 %s + 您将支付 + %s 支持 + 从图库中选择 + 设置 + 您尚未授予摄像头访问权限 + 摄像头访问权限被拒绝 + 您请求的代币未添加到您的钱包。请添加后重试。 + 代币未添加 + 抱歉,无法识别此二维码。 + 无法识别的二维码 + 您添加的所有代币均不支持此网络。请添加受支持的代币以发送加密货币。 + 未找到支持的代币 + 此二维码包含无法识别的参数: %s如果您继续操作,部分支付信息可能会丢失。 + 未知参数 + 奖励地址 + 我明白,我将完全失去对 Tangem Pay 卡及其上所有资金的访问权,且无法挽回 + 所有 Tangem 设备均已重置。 + 激活过程中出了问题。请逐一重置卡片。 + 卡片验证失败 + 请重置下一个设备以继续 + 拥有 root 权限的设备安全性较低。您的数据可能面临额外的风险。 + 检测到 root 访问 + 您发送 + + 此地址与代币不兼容 + + 使用固定利率时,您在互换交易时收到的金额将被锁定。这可以保护您免受交易过程中价格波动的影响。 固定利率 - 浮動利率意味著您最終收到的金額可能會根據您開始和完成置換之間的市場情況略有變化。 - 浮動利率 + 浮动利率意味着您最终收到的金额可能会根据您开始和完成互换之间的市场情况略有变化。 + 浮动利率 利率固定 + 出错了。请再试一次。 + 简单易用 + 让您的加密货币安全离线存储。轻薄如信用卡,安全胜过银行金库。 + 无助记词 + 一流的硬件钱包 + Tangem 冷钱包 + 网络费用是处理和确认您在区块链上的交易时所需的小额费用。 + 要开始质押,您必须通过交易充值 1 TON 来激活您的 TON 账户。资金会保留在您的账户中,因为此步骤仅用于激活账户以进行质押。 + 激活账户 + 网络费用已更改。请在继续操作前查看新金额。 + 网络费用已更新 + 年利率 %1$s%% + 您的质押奖励将在 5 个周期(约 25 天)后开始发放,在此期间您的委托将由网络注册和统计。 + 年收益率 + 年利率 (APY):显示您一年内通过复利计算可获得的总利息。复利是指您获得的利息会添加到您的本金中,因此您还可以获得该利息的利息。 + APY + 奖励将累积到您的质押余额中。已赚取资金: %s + 最高金额: %s + 要开始质押,您需要先激活您的 TON 账户。 + 激活账户 + Solana 的奖励会自动添加到您的质押余额中,无法单独显示。 + 批准后,您即允许智能合约在未来的交易中使用您的代币。 + 还在寻找其他代币?\n尝试搜索或探索其他加密货币! + 搜索任何代币,即使它还不在你的列表中。 + 使用搜索查找所需内容 + 您的资产 + 由于流动性低,您收到的资金可能会大大减少。请尝试较小的金额或另一个提供商。 + 账户余额不足,无法完成此交易。请减少收款金额或增加余额。 + 互换... + 此交易流动性不足,请减少金额或选择其他供应商。 + 交易额过大 + 我们非常乐意收到您的反馈。 + Tangem Pay 现已进入测试阶段 + 卡片已冻结 + 卡片支付 + 存款 + 争议 + 探索交易 + 服务费 + 费用 + 确保您的资金安全。您可以随时解冻。 + 冻结您的卡片? + 冻结卡片失败,请稍后再试。 + 冻结 + 您的卡片已被冻结。 + 获取帮助 + 原因: %s + %s · %s + MCC %s + 其他 + 无法在已 root 的设备上使用 + 已完成 + 拒绝 + 待定 + 已反转 + 条款、费用和限制 + 条款和限制 + 银行拒绝了这项交易请求。 + 这笔费用用于支付您办理转账时的费用。 + 商家部分或全部撤销了交易 + 继续使用您的资金。您可以随时冻结资金。 + 要解冻您的卡片? + 卡片解冻失败,请稍后再试。 + 您的卡片已解冻。 + 提款 + 无法在已root的设备上使用 + 从主屏幕隐藏 KYC 页面 + 增加资金 + 充值选项 + 添加到 Google 钱包 + 卡号 + 更改PIN码 + 该卡已完全准备好用于支付。 + 已创建 PIN 码 + CVC + 数据加载失败,请稍后再试。 + 到期 + 冻结卡片 + 隐藏详情 + 隐藏 + 打开 Google 钱包 + 只需轻点几下即可设置 Tangem Pay,然后即可开始使用 Google Pay 付款。 + 只需轻点几下即可设置 Tangem Pay,然后即可开始使用 Apple Pay 付款。 + 将您的卡片添加到 Google Pay + 将您的卡片添加到 Apple Pay + 打开 Google 钱包 + 点击右上角的“+”按钮 + 打开 Apple 钱包 + 点击“添加卡片” + 点击“借记卡或信用卡” + 手动输入卡片信息 + 使用发送到您设备上的 OTP 来验证卡片。 + 一切就绪!您的卡已可以使用。 + 将卡片添加到 Google Pay + 将卡片添加到 Apple Pay + PIN码 + 分享您的地址或出示二维码 + 检测到技术问题。请稍后再试或联系技术支持。 + 目前无法接收 + 显示 + 显示详情 + 将您投资组合中的任何资产互换到卡片 + 卡片详情 + 解冻卡片 + 如果忘记了,请返回应用查看。 + 您的PIN码 + 提款 + 目前无法提款 + 当前提款交易完成之前,您无法发起新的提款交易或互换交易。 + 提款进行中 + 卡片设置 + 更改PIN码 + 如果忘记了,请返回应用程序。 + 我明白,我将完全失去对 Tangem Pay 卡及其上所有资金的访问权,且无法挽回 + 发卡失败 + 发生技术故障,请点击下方按钮重试。 + 发生技术故障,请联系技术支持。 + 免费领取您的 Tangem Visa 虚拟卡 + 获取 Tangem Pay + 前往支持页面 + 通常需要最多15分钟 + 设置您的 Tangem 卡 + 正在发放您的卡片 + 该卡通常会在 5 分钟内自动发放。极少数情况下,如果需要人工审核,则可能需要长达 48 小时。 + Tangem Pay + 确认取消 + 您确定要停止 KYC 流程吗?您可以随时返回继续。 + 我们无法验证您的个人资料。如有任何疑问,请联系客服。 + 很抱歉,我们无法验证您的身份。 + KYC 被拒 + KYC正在进行中 + 查看状态 + Tangem Pay 的 KYC 流程正在进行中 + 文件通常会在 5 分钟内自动验证。极少数情况下,如果需要人工审核,则可能需要长达 48 小时。 + 已拒绝 + 隐藏 KYC 块 + 抱歉,我们无法验证 + 您的个人资料。 + 免费领取您的 Tangem Visa 虚拟卡 + 使用 USDC 进行日常支付 + 获取卡片 + 数字卡可与 Apple Pay 和 Google Pay 一起使用 + 随意支配您的资产 + 购买无需支付任何额外费用 + 实际支付金额与所示金额一致 + 将在不透露您的地址和资产的情况下创建一个单独的付款账户 + 无与伦比的隐私保护 + 几分钟内即可获得免费的 Tangem Pay 卡 + 支付账户 + 支付账户未同步 + 无效PIN码:请避免使用连续或重复的密码。 + 我们正在修复技术问题,请稍后再试。 + 服务暂时不可用 + 无法显示详细信息。但刷卡支付功能仍然可用。 + 设置 PIN 码 + 会话已过期 + 恢复访问权限 + 使用 USDC 进行日常支付 + Tangem Pay暂时无法使用。 + Tangem Pay + 点击下方按钮恢复访问权限 + 您的Polygon链上 USDC 余额与您的卡片余额不同,并在购买后 2 个工作日内更新。购物退款的资金不会退还至您的链上余额,也不能提现,但会保留在您的卡片余额中用于购物。 + 请注意 + 您的PIN码 + 授权已被撤销。您的资金仍处于收益模式。如需执行操作,请进入收益模式并重新授予权限。 + 不适用 + 显示二维码 + 为 %s + 之前的授权将被撤销,并颁发新的授权。网络将对每次此类操作收取代币批准费。您将在交易记录中看到一笔金额为零的交易,作为撤销授权的证据。 + 交易金额超过先前授权的金额。\n请更新授权以继续 + 更新权限 + 升级到硬件钱包 + 实时提醒交易、兑换和重要更新。 + 交易提醒 + 抢先体验最新功能和专属优惠。 + 专题报道和新闻更新 + 启用推送通知,即可在资金到账时收到提醒。 + 不要错过任何一笔交易 + 如果您在没有备份的情况下删除此钱包,您将永久失去对您的资金的访问权限。 + 选择您的钱包类型 + 扫描您的 Tangem 卡或指环以恢复卡号或从其他钱包导入卡号。 + 创建硬件钱包 + 想购买 Tangem 钱包吗? + 导入助记词 + 在手机上恢复您的钱包或从其他应用程序导入—方便,但安全性不如 Tangem 卡。 + 选择哪个? + 多部分交易 + 为了顺利完成交易,您的交易将被拆分成多个部分。您需要多次刷卡才能完成交易。 + 交易正在处理中。请多次用卡轻触以完成交易。 + 交易进行中 + 创建 Tangem 钱包 + 开始迁移 + 复制 + 要继续使用您的资金,请根据 Clore 官方指南开始迁移。 + 该网络不支持信息签名 + 无法签署信息。请重试。 + 根据 Clore 的官方文档,所有在 12 月 21 日之前收到的 Clore 代币(ERC-20 代币)都将迁移到 Clore;此日期之后收到的代币则不会迁移。迁移方案即将推出,敬请期待。 + 消息 + 打开索赔通道 + 要继续使用您的 Clore 代币,您必须根据索赔通道上的信息完成代币迁移。 + Clore 网络迁移 + 签署 + 签名 + Clore 网络迁移 + 此交易对不支持互换。请选择其他代币重试。 + 不支持的互换对 + 所需区块链 %s 尚未添加到您的投资组合中。请先添加,然后再进行连接。 + 将区块链添加到投资组合 + 要继续,请将您的 dApp 会话重新连接到所需的区块链网络 %s。 + 区块链网络未连接 + 检查您的区块链连接 + 请求超时 + 该区块链 %s Tangem Wallet 不支持,无法连接。 + 不支持的区块链 + 通过批准,您允许 dApp 或智能合约在未来的交易中使用代币。 + 已经拥有 Tangem 钱包? + 数千种资产 + 顶级硬件钱包 + 快速发货 + 一触即开始 + 无缝且安全 + 无助记词 + 简单易用 + 用 Tangem 创建硬件钱包。像银行卡一样纤薄,像银行保险库一样安全。 + 创建或导入软件钱包 + 在您的手机上创建或导入软件钱包。 + 从手机钱包开始 + 其他方法 + 使用 Tangem 硬件钱包 + 了解更多并购买 + 启用收益模式后,所有未来充值到此地址的资金都将转入 Aave。您仍然可以自由管理您的资金。 + 你的 %s 提供给 Aave + 供应 %1$s %2$s 到 Aave + 批准 + 您的代币授权已被撤销。请重新授权代币以恢复服务功能。 + 需要批准 + 费用将被扣除,您的资产将被重新提供。 + 要继续产生收益,需要获得批准。 + 确认批准 + 您的资金目前已提供给 Aave 协议,但您可以随时对其进行管理。 + 你的 %s 提供给 Aave + 无法加载图表... + 供应 %1$s %2$s 到 Aave。 + 禁用收益模式 + APY %1$s%% + 可用 + 当前年收益率 + 充值贷款时,将从余额中扣除不超过 %1$s 的网络费。 + 目前网络费用过高,无法执行借贷。一旦降至 %1$s 或以下,将立即提供资金。 + 我的资金 + 您的 %1$s 现已部署到 Aave 并产生收益。您持有 %2$s 这些代币代表您的余额,并会自动累积收益。当您充值时,扣除手续费后,资金将提供给 Aave 以产生更多收益。 + 收益模式 + 总收益 + 转至Aave + 探索 Aave + 这是 %s上的当前供应费。实际费用将显示在激活选项卡上。 + 当前费用 + 在扣除交易费后,今后所有 %s 充值都将自动提供给 Aave。 + 以后每次充值都会扣除大约 %1$s (%2$s) 的网络费,但不会超过 %3$s (%4$s) 的限额。 + 如果网络费用超过最高限额,交易将无法完成,直到网络费用降低为止。您可以稍后更改此限额。 + 最高费用 + 最低金额根据当前网络费用计算,确保不超过充值金额的 4%% ,相当于最低 %1$s (%2$s)。 + 最低充值金额 + 充值费用政策 + Tangem 还从产生的收益中提取 15% 服务费。 + 一旦网络费用降低或您的余额达到最低金额要求,您的资金将自动转入 Aave。 + 由于市场活跃,手续费高于平时。您可以立即进行交易,也可以稍后手续费降低时再来查看。 + 高昂的网络费用 + 历史回报 + 启动 %1$s%% 您余额的APY + 您的代币在收益模式下的授权已被撤销。请打开代币以重新授予权限。 + 需要代币批准 + 检查您的网络连接 + 网络费用信息无法获取 + 每次充值都将自动提供给 Aave。 + 您账户上的所有 %1$s 都将自动供应给 Aave。 + 自动供应至Aave + 您可以随时随地发送、兑换或出售您的资金。 + 无锁仓 + 它是如何运作的? + Aave 是一个链上协议,提供非托管流动性市场,使用户能够以可变利率累积收益。 + 去中心化和自托管 + 使用此服务即表示您同意服务提供商的条款\n%1$s 和 %2$s + 连接到 Aave + 启动 %1$s%% APY\非您的余额 + Aave %1$s%% - 可变利率 + 浮动利率 + Aave + 平均值 %s + 去年的收益 + 当前利率始终是浮动的,由 Aave 的链上智能合约根据实时供求关系自动计算。 + 技术支持 + 利率为浮动利率 + 充值后,您的资金将自动转入 Aave 账户,开始产生收益。 %s 将从中扣除交易手续费。 + 供应资产 + 您的 %s 将提供给 Aave ,没有锁仓,并且可以完全访问。 + 请参见充值费用政策 + 您的下一笔充值将自动发送至 Aave。 + 您未来所有的收入 %1$s 存款将自动存入 Aave。 + 活跃 + 暂停 + 禁用收益模式 + 关闭此功能会将您的资产从 Aave 提取,并将其转换回 %s 放入钱包,同时收益累积停止。 + 退出收益模式时,区块链会收取网络费用。 + 禁用收益模式 + 供应 + 供应 APY + APY + 利息自动累积。 + 利息自动累积 + 收益模式 + 启用收益模式 + 收益模式 + 收益模式合约部署 + 已启用收益模式 + %1$s 供应给 Aave + 已禁用收益模式 + %1$s 已从 Aave 提款 + 收益模式已初始化 + 收益模式重新激活 + 供应给 Aave + %1$s 供应给 Aave + 从 Aave提款 + 自动 + 添加 %1$s %2$s 以支付交易网络费。 + 无法覆盖 %s 费用 + 收益模式暂时不可用。请稍后再试。 + 收益模式不可用 + 无法加载图表... diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 5b19f4c6fd..6321abad93 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -80,6 +80,10 @@ Add tokens Choose the token you want to receive Choose the token you want to swap + Add to your portfolio + Add tokens + Sorting and grouping + Organize tokens Choose network Add custom token Manage tokens @@ -477,7 +481,13 @@ Send funds using only Dynamic addresses Dynamic Addresses is enabled. Custom \"change\" and \"index\" are unavailable. + You are turning off Dynamic Addresses. Funds will now be received on your fixed address. + A network fee applies to consolidate funds into the fixed address. + Disable Dynamic Addresses + Disable dynamic addresses + Dynamic addresses disabled Dynamic addresses enabled + Dynamic address Use a new address for each transaction to reduce traceability and improve on-chain privacy. Enhanced Privacy Easily receive funds in UTXO-based networks with automatic address generation — no manual address management required. @@ -584,6 +594,8 @@ Choose which token to use to pay\nthe network fee. %s Choose token Market & News + Show All + Show less Tangem AI Trending Now The following information is optional. You can erase it if you don\'t want to share it. @@ -720,6 +732,7 @@ Mana limit The Koinos network requires Mana for network fees. You have %1$s/%2$s Mana Mana level + Add & Manage To begin tracking your crypto assets and transactions, add tokens Manage tokens Scan QR code to send funds or connect to an app @@ -1535,6 +1548,7 @@ Trade too large We would be happy to receive your feedback Tangem Pay is now in beta + Unable to rename card Card frozen Card payment Deposit @@ -1542,6 +1556,17 @@ Explore transaction Service fees Fee + Replace card + Replace your card? + This generates a new set of card details. Your old details will stop working. You can\'t undo this. + Replacement fee + Replace card + Replacing your digital card + Usually takes up to 5 minutes. In rare cases, up to 48 hours. + Insufficient funds to replace the card + Unable to cover fee + Deposit USDC to payment account to cover the issuing fee + Replacement fee info unreachable Keep your money safe. You can unfreeze anytime. Freeze your card? Failed to freeze the card. Try again later. @@ -1568,7 +1593,9 @@ Your card is unfrozen. Withdrawal Unable to use on rooted device + Available balance Hide KYC from main screen + Tangem Pay Card 1 Add funds Top-up options Add to Google Wallet @@ -1601,10 +1628,13 @@ Share your address or show QR-code Technical issues detected. Please try again later or contact support. Receive unavailable now + Only letters and numbers are allowed. + Invalid characters Reveal Show details Swap any asset in your portfolio for card Card details + Please try again later. Unfreeze Card Come back to the app if you forget it. Your PIN code @@ -1612,12 +1642,16 @@ Withdraw unavailable now You can\'t initiate swap or new withdrawal till the current one is finished Withdrawal in progress + Card settings Change PIN-code Come back to the app if you forget it. + Digital card I understand that I will completely lose access to my Tangem Pay Card and all funds on it without the possibility of recovery Failed to issue card A technical error has occurred, please try again by clicking the button below. A technical error has occurred, please contact support. + Feature will be availible soon + You will be available to issue additional cards for your payment account Get your free Tangem Visa virtual card Get Tangem Pay Go to Support @@ -1649,6 +1683,7 @@ A separate payment account will be created without disclosing your addresses and assets Unrivaled privacy Get your free Tangem Pay Card in minutes + Pay Support Payment account Payment account is not synced Invalid PIN: avoid sequences or repeats @@ -1661,6 +1696,7 @@ Use USDC for everyday payments Tangem Pay is temporarily unreachable Tangem Pay + USDC on Polygon network Click the button below to restore access Your on-chain USDC Polygon balance differs from your card balance and updates within 2 business days after a purchase. Funds from refunded purchases won’t be returned your on-chain balance or be available for withdrawal, but will stay on your card balance for purchases. Please note @@ -1682,6 +1718,8 @@ Selling %s is not supported by current providers, but we are working to add more options. Staking %s is not supported by current providers, but we are working to add more options. Approval has been revoked. Your funds remain in Yield mode. To perform actions, please go to Yield mode and grant permission again. + Available balance + Total balance Generate XPUB Hide You are about to hide this token from the main screen. You can add it back anytime through the manage tokens page. @@ -1691,6 +1729,10 @@ Staking Service %1$s token in %%image%% %2$s network Token in %%image%% %1$s network + %s network + %1$s in %2$s network + %1$s in %%image%% %2$s + %1$s in %2$s %%image%% The %1$s (%2$s) token is the main currency on the %3$s network and cannot be hidden as long as you have other tokens on this network in the list Unable to hide %s N/A @@ -2108,6 +2150,8 @@ At least one network is required for dApp connection Specify selected networks Successfully signed + Share Addresses + Addresses to share WalletConnect To Transaction request 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/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/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/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/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/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/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/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_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_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_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_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/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/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/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/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/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/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..a504298e11 100644 --- a/data/dynamic-addresses/build.gradle.kts +++ b/data/dynamic-addresses/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { // region Project - Libs implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(tangemDeps.card.core) // endregion // region DI 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..9f36682a75 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,11 +5,13 @@ 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.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 @@ -31,29 +33,46 @@ internal class DefaultDynamicAddressesRepository( .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) 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) 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 +89,41 @@ internal class DefaultDynamicAddressesRepository( return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, 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 +134,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 +149,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..6eae2bf3b5 --- /dev/null +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt @@ -0,0 +1,42 @@ +package com.tangem.data.dynamicaddresses + +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +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) { + 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/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index 5f39f9f870..76ef2e52f0 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,7 @@ 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,10 +10,10 @@ 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.logging.TangemLogger import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.flow.MutableStateFlow @@ -23,23 +23,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 appVersionProvider app version 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, ) : FeedbackRepository { private val blockchainsErrors = MutableStateFlow>(emptyMap()) @@ -68,16 +65,12 @@ 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) } @@ -91,7 +84,7 @@ internal class DefaultFeedbackRepository( } 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..6d4d9176c1 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,7 +9,6 @@ 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 dagger.Module import dagger.Provides @@ -29,7 +28,6 @@ internal object FeedbackModule { walletManagersStore: WalletManagersStore, emailSender: EmailSender, appVersionProvider: AppVersionProvider, - getSelectedWalletUseCase: GetSelectedWalletUseCase, ): FeedbackRepository { return DefaultFeedbackRepository( appLogsStore = appLogsStore, @@ -37,7 +35,6 @@ internal object FeedbackModule { emailSender = emailSender, appVersionProvider = appVersionProvider, 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/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 86% 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..8bef0658ac 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,6 +1,6 @@ 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.datasource.local.network.entity.NetworkStatusDM.CurrencyId import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId.Companion.CONTRACT_ADDRESS_DELIMITER @@ -12,13 +12,13 @@ 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 { @@ -31,7 +31,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 +43,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 +80,17 @@ internal class CurrencyIdConverter( return when (derivationPath) { is Network.DerivationPath.Card -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = rawNetworkId, + rawId = blockchainId, derivationPath = derivationPath.value, ) } is Network.DerivationPath.Custom -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = rawNetworkId, + rawId = blockchainId, derivationPath = derivationPath.value, ) } - is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(rawNetworkId) + is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(blockchainId) } } } \ 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..4ca75519b9 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,10 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkAmountsConverterTest { - private val rawNetworkId = "ETH" + private val rawNetworkId = "ethereum" 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 = rawNetworkId, derivationPath = derivationPath) @Test fun convert() { @@ -47,12 +47,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 +63,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 84% 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..9e129e6cd4 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 @@ -13,12 +13,12 @@ import org.junit.jupiter.params.ParameterizedTest [REDACTED_AUTHOR] */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CurrencyIdConverterTest { +class NetworkCurrencyIdConverterTest { - private val rawNetworkId = "ETH" + private val rawNetworkId = "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 = rawNetworkId, derivationPath = derivationPath) @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -48,7 +48,7 @@ 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( @@ -71,7 +71,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -82,7 +82,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -93,7 +93,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -104,7 +104,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -114,7 +114,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 +123,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 +154,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 +172,7 @@ class CurrencyIdConverterTest { ), ConvertBackModel( value = CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), expected = Result.success( CurrencyId.createTokenId( 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..b18f2e14d2 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,7 +13,7 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkYieldSupplyStatusConverterTest { - private val rawNetworkId = "ETH" + private val rawNetworkId = "ethereum" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" private val converter = NetworkYieldSupplyStatusConverter(rawNetworkId, derivationPath) @@ -38,8 +38,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 +49,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..0af092a1ca 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, @@ -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/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/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/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index a2c353ef57..eea56a309b 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 @@ -91,12 +91,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 +143,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 } } @@ -198,10 +198,10 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( } 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, @@ -255,8 +255,8 @@ 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, @@ -316,7 +316,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, 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/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/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/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index a42852436a..cfa7822955 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,14 @@ package com.tangem.data.pay.converter -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convert -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convertBack +import com.tangem.data.pay.entity.TangemPayCurrencyFactory +import arrow.core.getOrElse 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.wallet.UserWalletId +import javax.inject.Inject +import javax.inject.Singleton /** * Two-way converter between [PaymentAccountStatusValue] and [PaymentAccountStatusValueDM]. @@ -15,10 +18,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( @@ -36,6 +41,7 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), + displayName = value.displayName?.value, ) is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveCard( isLocked = false, @@ -47,6 +53,7 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), + displayName = value.displayName?.value, ) is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed( customerId = value.customerId, @@ -60,7 +67,7 @@ internal object PaymentAccountStatusValueDMConverter : } } - override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { + fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { return when (value) { is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed( @@ -80,6 +87,8 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } }, ) } else { PaymentAccountStatusValue.Loaded( @@ -92,6 +101,8 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } }, ) } is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( 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..7d55c0625d 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 @@ -6,9 +6,11 @@ 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.* +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase @@ -24,6 +26,7 @@ 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.tangempay.GetTangemPayCurrencyStatusUseCase @@ -69,6 +72,10 @@ internal interface TangemPayDataModule { @Singleton fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository + @Binds + @Singleton + fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository + @Binds @Singleton fun bindTangemPayCryptoCurrencyFactory( @@ -112,6 +119,7 @@ internal interface TangemPayDataModule { @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, scope: AppCoroutineScope, + converter: PaymentAccountStatusValueDMConverter, ): PaymentAccountStatusesStore { return PaymentAccountStatusesStore( runtimeStore = RuntimeSharedStore(), @@ -124,6 +132,7 @@ internal interface TangemPayDataModule { produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") }, scope = scope, ), + converter = converter, scope = scope, ) } @@ -139,6 +148,14 @@ internal interface TangemPayDataModule { ) {} } + @Provides + @Singleton + fun provideGetTangemPayCryptoCurrencyStatusUseCase( + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + ): GetPaymentAccountCryptoCurrencyStatusUseCase { + return GetPaymentAccountCryptoCurrencyStatusUseCase(paymentAccountStatusSupplier) + } + @Provides @Singleton fun provideTangemPayMainScreenCustomerInfoUseCase( 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 fa38de8fbd..1cb938336b 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,7 @@ 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.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.OrderData @@ -34,6 +36,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val customerOrderRepository: CustomerOrderRepository, private val deviceSecurity: DeviceSecurityInfoProvider, private val dispatchers: CoroutineDispatcherProvider, + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -137,7 +140,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) @@ -164,7 +167,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 } @@ -242,11 +245,11 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( return onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId) .fold( ifLeft = { it.mapToPaymentAccountStatus() }, - 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 return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) { @@ -257,6 +260,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) { convertToContentState( + userWalletId = userWalletId, productInstance = productInstance, cardInfo = cardInfo, customerId = requireNotNull(customerId) { "CustomerId must not be null" }, @@ -267,10 +271,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } private fun convertToContentState( + userWalletId: UserWalletId, productInstance: CustomerInfo.ProductInstance, cardInfo: CustomerInfo.CardInfo, customerId: String, ): PaymentAccountStatusValue { + val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) return when (productInstance.frozenState) { TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked( source = StatusSource.ACTUAL, @@ -282,6 +288,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( isPinSet = cardInfo.isPinSet, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, + displayName = productInstance.displayName, ) else -> PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, @@ -293,6 +301,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( isPinSet = cardInfo.isPinSet, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, + displayName = productInstance.displayName, ) } } 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 8253961561..600f2f3ebc 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.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -15,6 +16,7 @@ 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.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.wallet.UserWallet @@ -198,7 +200,14 @@ internal class DefaultOnboardingRepository @Inject constructor( } cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState) - ProductInstance(id = instance.id, cardId = instance.cardId, frozenState = cardFrozenState) + val displayName = instance.displayName?.ifEmpty { null } + + ProductInstance( + id = instance.id, + cardId = instance.cardId, + frozenState = cardFrozenState, + displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null, + ) } return CustomerInfo( customerId = response?.id, 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..abffaa87fc --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -0,0 +1,104 @@ +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.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.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(response.result.orderId, OrderStatus.fromString(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..814476fa34 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 @@ -15,10 +15,12 @@ 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.SetPinRequest +import com.tangem.datasource.api.pay.models.request.UpdateCardDisplayNameRequest 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 @@ -318,6 +320,26 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( } } + override suspend fun updateCardDisplayName( + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either { + return catch( + block = { + requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.updateCardDisplayName( + authHeader = authHeader, + body = UpdateCardDisplayNameRequest(displayName = displayName.value), + ) + }.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..62accbccf7 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 @@ -29,6 +29,7 @@ internal typealias WalletIdWithPaymentStatusDM = Map, private val persistenceDataStore: DataStore, + private val converter: PaymentAccountStatusValueDMConverter, scope: AppCoroutineScope, ) { @@ -39,7 +40,7 @@ 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) }, ) @@ -87,7 +88,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/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..3401f5d920 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)) { @@ -463,9 +469,12 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + 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 +482,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 +507,26 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + 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 +877,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/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/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/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 71% 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..11df5ba962 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,18 +1,18 @@ -package com.tangem.domain.tokensync.usecase +package com.tangem.domain.assetsdiscovery.usecase import arrow.core.Either 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, ) { @@ -23,9 +23,9 @@ class StartTokenSyncUseCase( activeSyncJobs[userWalletId]?.cancel() activeSyncJobs[userWalletId] = appCoroutineScope.launch { try { - tokenSyncRepository.runSync(userWalletId) + assetsDiscoveryRepository.runDiscovery(userWalletId) applyDiscoveredTokens(userWalletId) - tokenSyncRepository.completeSync(userWalletId) + assetsDiscoveryRepository.completeDiscovery(userWalletId) } catch (e: Exception) { TangemLogger.e("Token sync failed for wallet: $userWalletId", e) } finally { @@ -36,18 +36,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 +57,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 +67,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/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..4c3b3cd2bf --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt @@ -0,0 +1,60 @@ +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 + if (!blockchain.isBip44DerivationStyleXPUB()) 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..8cdf4b0c12 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,10 @@ 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 } \ 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/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/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/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/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt new file mode 100644 index 0000000000..515857f8a7 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.models + +import java.math.BigDecimal + +data class TangemPayReissueCardFee( + val amount: BigDecimal, + val currencyCode: String, +) \ No newline at end of file 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/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 fc44ccc105..8e74069ff6 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,13 @@ package com.tangem.domain.models.account import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance +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.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. @@ -104,7 +108,30 @@ sealed class PaymentAccountStatusValue { val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() + val cryptoCurrency: CryptoCurrency.Token, + val displayName: CardDisplayName?, + ) : 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 a state where the payment account is successfully loaded with complete information. @@ -130,7 +157,30 @@ sealed class PaymentAccountStatusValue { val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() + val cryptoCurrency: CryptoCurrency.Token, + val displayName: CardDisplayName?, + ) : 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 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/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/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/UserAssetSearchEntry.kt b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt index fd64a23e24..c6a3d45854 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt @@ -2,6 +2,7 @@ package com.tangem.domain.search.model 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 @@ -10,5 +11,6 @@ data class UserAssetSearchEntry( 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/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..7a63289486 --- /dev/null +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.search.model + +sealed interface UserAssetSearchItem { + + data class Single(val entry: UserAssetSearchEntry) : 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..0b6b37be48 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 @@ -9,10 +9,12 @@ 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.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,6 +87,44 @@ 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, @@ -103,6 +146,7 @@ class GetSearchResultsUseCase( 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 cdf1a16bad..3b67c72c6f 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/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..ccaee70b62 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 @@ -190,7 +190,7 @@ 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, ) } 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/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 e2424a061a..5b2be89b69 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 @@ -11,4 +12,5 @@ data class TangemPayDetailsConfig( val cardFrozenState: TangemPayCardFrozenState, val cardNumberEnd: String, val chainId: Int, + 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 a35e9adaea..7c93887879 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,6 @@ package com.tangem.domain.pay.model +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 @@ -49,6 +50,7 @@ data class CustomerInfo( val id: String, val cardId: String, val frozenState: TangemPayCardFrozenState, + val displayName: CardDisplayName?, ) data class CardInfo( 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..c304832170 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,9 +1,22 @@ package com.tangem.domain.pay.model +import java.util.Locale + enum class OrderStatus { UNKNOWN, // TODO remove it after TangemPay accounts refactor TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED NEW, PROCESSING, COMPLETED, CANCELED, + ; + + companion object { + fun fromString(value: String) = when (value.uppercase(Locale.US)) { + "NEW" -> NEW + "PROCESSING" -> PROCESSING + "COMPLETED" -> COMPLETED + "CANCELED" -> CANCELED + else -> UNKNOWN + } + } } \ No newline at end of file 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/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 088b34d193..80aa3ea5c9 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,9 @@ interface TangemPayCardDetailsRepository { fun cardFrozenState(cardId: String): Flow suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? + + suspend fun updateCardDisplayName( + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): 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..2342faff2c --- /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.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..fe66dc36f7 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt @@ -0,0 +1,34 @@ +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.firstOrNull + +class GetPaymentAccountCryptoCurrencyStatusUseCase( + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, +) { + + suspend operator fun invoke( + 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 + is PaymentAccountStatusValue.Locked -> 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/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index f7eb452cc2..03f5d83bfe 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 @@ -192,6 +192,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..854c807db6 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,6 +295,8 @@ 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? 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/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/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/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..f46795f50f --- /dev/null +++ b/features/common-features/api/build.gradle.kts @@ -0,0 +1,27 @@ +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 { + /** Api */ // todo swap delete after move portfolio selector + implementation(projects.features.account.api) + + /* Project - Domain */ + implementation(projects.domain.models) + implementation(projects.domain.markets) + + /* 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..2261f0335d --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt @@ -0,0 +1,13 @@ +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..9ab9c5a86c --- /dev/null +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -0,0 +1,91 @@ +package com.tangem.features.commonfeatures.api.addtoportfolio + +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.account.PortfolioFetcher +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams +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 portfolioFetcher: PortfolioFetcher + val state: StateFlow + + fun setTokenNetworks(networks: List) + fun setTokenParams(token: TokenMarketParams) + + 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?, + ) + + interface Factory { + fun create(scope: CoroutineScope, settings: Settings, analyticsParams: AnalyticsParams): AddToPortfolioManager + } + + /** + * Immutable settings + */ + data class Settings( + val shouldSkipTokenActionsScreen: Boolean = false, + ) { + companion object { + val DefaultMarket = Settings( + shouldSkipTokenActionsScreen = false, + ) + val ChooseToken = Settings( + shouldSkipTokenActionsScreen = true, + ) + } + } + + /** + * Mutable parameters + * Updates may trigger reload [State] + */ + data class Params( + val networks: List, + val token: TokenMarketParams, + ) + + 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 + + suspend fun token(): TokenMarketParams = paramsFlow.first().token + + fun onDismiss() + fun onSuccessAdded(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/AddToPortfolioPreselectedDataComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt similarity index 94% rename from features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt index c7feac3a8c..59b4f03918 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.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.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent 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/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..01aef9c2e7 --- /dev/null +++ b/features/common-features/impl/build.gradle.kts @@ -0,0 +1,81 @@ +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" +} + +dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + // todo swap delete after move portfolio selector + implementation(projects.features.account.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.uiMarkets) + implementation(projects.common.routing) + + /** 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) +} \ 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 95% 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..c649a267f2 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.tangem.features.commonfeatures.impl.R import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -20,8 +21,7 @@ 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.impl.addtoportfolio.model.AddToPortfolioRoutes @Composable internal fun AddToPortfolioBottomSheet( 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 76% 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..a893a9c1c4 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,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.ui.Modifier @@ -9,10 +9,10 @@ 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.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.impl.addtoportfolio.model.AddTokenModel +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 dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -36,7 +36,7 @@ internal class AddTokenComponent @AssistedInject constructor( } data class Params( - val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val eventBuilder: Flow, val selectedPortfolio: Flow, val selectedNetwork: Flow, val callbacks: Callbacks, 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 90% 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..6ce1279b9d 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,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 com.arkivanov.decompose.ComponentContext @@ -12,9 +12,9 @@ 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.impl.addtoportfolio.model.AddToPortfolioModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -71,7 +71,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( } override fun dismiss() { - params.callback.onDismiss() + model.addToPortfolioManager.onDismiss() } @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt similarity index 88% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt index 95efde9298..f298a48b32 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.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 com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -10,12 +10,13 @@ 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 com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.flowOf internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @@ -37,7 +38,7 @@ internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject con private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( context = child("addTokenComponent"), params = AddTokenComponent.Params( - eventBuilder = model.eventBuilder, + eventBuilder = flowOf(model.eventBuilder), callbacks = model, selectedPortfolio = model.selectedPortfolio, selectedNetwork = model.selectedNetwork, 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 81% 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..c5e6430356 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,6 +8,7 @@ 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 @@ -15,10 +16,9 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent 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.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -63,8 +63,8 @@ internal class TokenActionsComponent @AssistedInject constructor( ) data class Params( - val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, - val data: Flow, + val eventBuilder: 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..6f72186a1b --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,91 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics + +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM +import com.tangem.core.analytics.models.AnalyticsEvent + +// todo swap unify with EarnAnalyticsEvent, AddToPortfolioFlow +internal class PortfolioAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val tokenSymbol: String, + val source: String?, + ) { + + 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 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/add/impl/converter/AvailableToAddDataConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt similarity index 92% 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..32f382fdcb 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,4 +1,4 @@ -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 @@ -13,9 +13,9 @@ 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.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( 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 79% 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..ed70205959 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,14 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.converter 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.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 +16,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) @@ -48,7 +47,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..871d7f7c44 --- /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.api.addtoportfolio.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.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/di/AddToPortfolioModelModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt similarity index 66% 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..d0e2845d69 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.AddToPortfolioPreselectedDataModel +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 dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt similarity index 84% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 711c38b1f3..a0b119a4ab 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -1,9 +1,11 @@ -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.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.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 @@ -17,15 +19,14 @@ 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.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.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.features.commonfeatures.api.addtoportfolio.* +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.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.Job @@ -61,14 +62,11 @@ internal class AddToPortfolioModel @Inject constructor( /* Flows that hold state and provide it to child models */ val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() - val tokenActionsData: 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 addToPortfolioManager: AddToPortfolioManager = params.addToPortfolioManager + val portfolioFetcher: PortfolioFetcher = addToPortfolioManager.portfolioFetcher + val eventBuilder: MutableSharedFlow = replayMutableSharedFlow() val featureData: Flow = combineFeatureData() @@ -85,32 +83,27 @@ internal class AddToPortfolioModel @Inject constructor( @Suppress("LongMethod") private fun startAddToPortfolioFlow() { channelFlow { - fun finishFlow() { - params.callback.onDismiss() - channel.close() - } - - fun finishSuccessFlow(addedToken: CryptoCurrencyStatus) { - params.callback.onSuccess(addedToken.currency) + fun finishSuccessFlow(result: AddToPortfolioManager.Result) { + addToPortfolioManager.onSuccessAdded(result) channel.close() } val featureDataFlow: StateFlow = featureData - .filterIsInstance() + .filterIsInstance() .map { it.availableToAddData } .distinctUntilChanged() .stateIn(this) val isAccountMode = portfolioSelectorController.isAccountModeSync() + val tokenMarketParams = addToPortfolioManager.paramsFlow.first().token + val eb = PortfolioAnalyticsEvent.EventBuilder( + tokenSymbol = tokenMarketParams.symbol, + source = addToPortfolioManager.analyticsParams.source, + ) + eventBuilder.tryEmit(eb) // 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) } @@ -157,7 +150,7 @@ internal class AddToPortfolioModel @Inject constructor( // line of navigation to AddToken screen is finished; cancel the job, select a new root screen firstPartOfNavigation.cancel() - analyticsEventHandler.send(event = eventBuilder.popupToConfirm()) + analyticsEventHandler.send(event = eventBuilder.first().popupToConfirm()) navigation.replaceAll(AddToPortfolioRoutes.AddToken) var middleNavigationJob: Job? = null @@ -185,34 +178,39 @@ internal class AddToPortfolioModel @Inject constructor( val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() middleNavigationJob?.cancel() val selectedPortfolio = selectedPortfolio.first() + val result = AddToPortfolioManager.Result( + wallet = selectedPortfolio.userWallet, + account = selectedPortfolio.account.account, + addedCurrency = addedToken, + ) messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) - if (params.shouldSkipTokenActionsScreen) { - finishSuccessFlow(addedToken) + if (addToPortfolioManager.settings.shouldSkipTokenActionsScreen) { + finishSuccessFlow(result) } else { setupTokenActionsFlow(selectedPortfolio, addedToken) .onEach { cryptoCurrencyData -> tokenActionsData.emit(cryptoCurrencyData) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) } - .onEmpty { finishFlow() } + .onEmpty { finishSuccessFlow(result) } .launchIn(this) } callbackDelegate.onLaterClick.receiveAsFlow().first() - finishFlow() + finishSuccessFlow(result) } .catch { throwable -> TangemLogger.e("Error", throwable) - params.callback.onDismiss() + addToPortfolioManager.onDismiss() } .launchIn(modelScope) } - private fun logAccountSelector(isAccountMode: Boolean) { + private suspend fun logAccountSelector(isAccountMode: Boolean) { if (isAccountMode) { - analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) + analyticsEventHandler.send(eventBuilder.first().popupToChooseAccount()) } } @@ -254,7 +252,7 @@ internal class AddToPortfolioModel @Inject constructor( private fun setupTokenActionsFlow( selectedPortfolio: SelectedPortfolio, addedToken: CryptoCurrencyStatus, - ): Flow { + ): Flow { val timeFlow = channelFlow { val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } getCryptoCurrencyActionsUseCase( @@ -274,7 +272,7 @@ internal class AddToPortfolioModel @Inject constructor( }.collect() } return timeFlow.map { actionsState -> - PortfolioData.CryptoCurrencyData( + CryptoCurrencyData( userWallet = selectedPortfolio.userWallet, status = actionsState.cryptoCurrencyStatus, actions = actionsState.states, @@ -291,7 +289,7 @@ internal class AddToPortfolioModel @Inject constructor( data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null val availableToAddAccount = availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null - if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) + if (!isAccountMode) analyticsEventHandler.send(eventBuilder.first().addToPortfolioWalletChanged()) SelectedPortfolio( isAccountMode = isAccountMode, userWallet = availableToAddWallets.userWallet, @@ -327,7 +325,7 @@ internal class AddToPortfolioModel @Inject constructor( val accountIndex = account.account.account.derivationIndex return getTokenMarketCryptoCurrency( userWalletId = userWallet.walletId, - tokenMarketParams = addToPortfolioManager.token, + tokenMarketParams = addToPortfolioManager.token(), network = network, accountIndex = accountIndex, ) @@ -339,7 +337,7 @@ internal class AddToPortfolioModel @Inject constructor( private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> when (state) { - is AddToPortfolioManager.State.AvailableToAdd -> + is AddToPortfolioManager.State.Ready -> portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] ?: return@isEnabled false @@ -348,8 +346,7 @@ internal class AddToPortfolioModel @Inject constructor( ?.isAvailableToAdd == true return@isEnabled isAvailableAccount } - AddToPortfolioManager.State.Init, - AddToPortfolioManager.State.NothingToAdd, + AddToPortfolioManager.State.Loading, -> Unit } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt similarity index 95% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt index a28a4fd173..10e935ada2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.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.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.replaceAll @@ -22,17 +22,19 @@ 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.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.* +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.EarnAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent 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 +import kotlin.collections.get +import kotlin.collections.mapNotNull @Suppress("LongParameterList") internal class AddToPortfolioPreselectedDataModel @Inject constructor( @@ -163,7 +165,7 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor( } /** - * Creates [AvailableToAddData] for preselected network when token is already added in all networks. + * Creates [com.tangem.features.commonfeatures.api.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( 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 78% 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..e548f5b16f 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 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 89% 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..ad1eee5632 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.first() 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,7 +100,8 @@ internal class AddTokenModel @Inject constructor( userWalletId = accountId.userWalletId, currencyId = cryptoCurrency.id, network = cryptoCurrency.network, - ).firstOrNull() + ) + .firstOrNull() if (status == null) { processError(error = null) } else { 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 91% 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..3498931b1d 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 @@ -14,10 +14,10 @@ import com.tangem.core.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 80% 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..575c386ebd 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,7 +1,9 @@ -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 @@ -10,14 +12,13 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency 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.first import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -52,18 +53,18 @@ internal class TokenActionsModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() val uiState: StateFlow = params.data - .mapLatest { uiBuilder.build(it, tokenActionsHandler) } + .mapLatest { uiBuilder.build(it, tokenActionsHandler, analyticsEventBuilder.first()) } .stateIn( scope = modelScope, started = SharingStarted.Eagerly, initialValue = null, ) - private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) { - val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action) + private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch { + val event = analyticsEventBuilder.first().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/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt similarity index 61% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt index 8828010452..2e6e031449 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -1,16 +1,17 @@ -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.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions +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.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 com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import javax.inject.Inject @ModelScoped @@ -20,7 +21,11 @@ internal class TokenActionsUiBuilder @Inject constructor( ) { private val params = paramsContainer.require() - fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { + fun build( + data: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + ): TokenActionsUM { val status = data.status val tokenUM = TokenItemState.Content( id = status.currency.id.value, @@ -35,10 +40,10 @@ internal class TokenActionsUiBuilder @Inject constructor( return TokenActionsUM( token = tokenUM, onLaterClick = { - analyticsEventHandler.send(params.eventBuilder.getTokenLater()) + analyticsEventHandler.send(eventBuilder.getTokenLater()) params.callbacks.onLaterClick() }, - quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler), + quickActions = 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/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt similarity index 94% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt index 7b92e78e3b..a682ee6396 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.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.background @@ -24,8 +24,8 @@ 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 com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM +import com.tangem.features.commonfeatures.impl.R import kotlinx.collections.immutable.persistentListOf import java.util.UUID 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..a0aa017d17 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt @@ -0,0 +1,111 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui + +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.Settings +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 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 = AddToPortfolioManager.Params( + networks = internalParams.networks ?: return@transform, + token = internalParams.token ?: return@transform, + ) + emit(fullParams) + } + .distinctUntilChanged() + .shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1) + + override val state: MutableStateFlow = + MutableStateFlow(AddToPortfolioManager.State.Loading) + + init { + buildFlow() + .onEach { newState -> state.update { newState } } + .flowOn(dispatchers.default) + .launchIn(scope) + } + + override fun onDismiss() { + onDismiss.trySend(Unit) + } + + override fun onSuccessAdded(result: AddToPortfolioManager.Result) { + onSuccessAdded.trySend(result) + } + + override fun setTokenNetworks(networks: List) { + updateInternal(networks = networks) + } + + override fun setTokenParams(token: TokenMarketParams) { + updateInternal(token = token) + } + + private fun updateInternal(networks: List? = null, token: TokenMarketParams? = null) { + internalParamsFlow.update { prev -> + val newParams = ParamsInternal( + networks = networks ?: prev.networks, + token = token ?: prev.token, + ) + val shouldReload = prev.networks != newParams.networks || prev.token != newParams.token + if (shouldReload) state.update { AddToPortfolioManager.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, + ) + AddToPortfolioManager.State.Ready(data) + } + + @AssistedFactory + interface Factory : AddToPortfolioManager.Factory { + override fun create( + scope: CoroutineScope, + settings: Settings, + analyticsParams: AnalyticsParams, + ): DefaultAddToPortfolioManager + } + + private data class ParamsInternal( + val networks: List? = null, + val token: TokenMarketParams? = 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 94% 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..23d8df105c 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 @@ -188,7 +188,7 @@ private class TokenActionsContentPreviewProvider : PreviewParameterProvider get() = sequenceOf( TokenActionsUM( - quickActions = PortfolioTokenUM.QuickActions( + quickActions = QuickActions( actions = persistentListOf( QuickActionUM.Buy, QuickActionUM.Exchange(shouldShowBadge = true), 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..b0b0b9b56e --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt @@ -0,0 +1,10 @@ +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 + +internal data class TokenActionsUM( + val token: TokenItemState, + val quickActions: QuickActions, + val onLaterClick: () -> Unit, +) \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 742988403b..7fc4587458 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -19,7 +19,6 @@ 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) /* Project - Core */ @@ -49,6 +48,7 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.legacy) + implementation(projects.domain.settings) implementation(projects.domain.visa) /* SDK */ 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..e501fd3751 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 @@ -22,8 +22,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 @@ -61,7 +59,6 @@ internal class DetailsModel @Inject constructor( 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 +76,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 +116,6 @@ internal class DetailsModel @Inject constructor( .launchIn(modelScope) } - private fun bootstrapScreenState() { - appStateHolder.dispatch(LegacyAction.PrepareDetailsScreen) - } - private fun sendFeedback() { modelScope.launch { val userWallets = getWalletsUseCase.invokeSync() 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/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/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt index 7c9ffb7c0a..ab97474343 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,7 +29,10 @@ 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 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/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..a0b3a03088 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, ), ), @@ -128,8 +132,8 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( 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, ), ) @@ -265,4 +273,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..e8f7af83ef 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.AddToPortfolioPreselectedDataComponent 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 @@ -65,7 +65,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data object Search : Child + data class Search(val source: String) : Child } @Suppress("LongMethod") @@ -144,10 +144,18 @@ internal class FeedEntryChildFactory @Inject constructor( addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, ) } - 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..8ddecd2dc6 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 @@ -34,7 +34,7 @@ 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.components.feed.FeedBottomSheetRoute -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.EarnModel import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.earn.EarnContent @@ -148,6 +148,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..cb39402dc7 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 @@ -20,8 +20,8 @@ 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.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent.Params import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.ui.feed.FeedList 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..ecbaa6c87a 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,7 +2,7 @@ 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 +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent internal sealed interface FeedBottomSheetRoute { 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..c4ac5551aa 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 @@ -17,6 +17,7 @@ import androidx.compose.ui.res.vectorResource import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child @@ -228,6 +229,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/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/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..6c8cb07a7c 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,8 @@ 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.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.models.AccountStatusList @@ -28,7 +30,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 @@ -248,7 +249,7 @@ 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, 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..5474899d5c 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,10 @@ internal class PortfolioTokenUMConverter( private val isBalanceHidden: Boolean, private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, private val tokenActionsHandler: TokenActionsHandler, -) : Converter { +) : Converter { fun convertV2( - value: PortfolioData.CryptoCurrencyData, + value: CryptoCurrencyData, isQuickActionsShown: Boolean, onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit, ): PortfolioTokenUM { @@ -45,7 +42,7 @@ internal class PortfolioTokenUMConverter( ) } - 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)) }, @@ -63,62 +60,4 @@ internal class PortfolioTokenUMConverter( quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), ) } - - 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..b1ad93e9b2 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 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..ebc36fbbd5 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,7 +127,7 @@ 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/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..b93cac2f9f 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, 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..7ac509eccf --- /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.search.model.UserAssetSearchEntry + +internal sealed interface SearchBottomSheetRoute { + + data class TokenSelector( + val entries: List, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val onTokenSelected: (UserAssetSearchEntry) -> 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..a36895267e --- /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.search.model.UserAssetSearchEntry +import com.tangem.features.feed.model.search.SearchTokenSelectorModel +import com.tangem.features.feed.ui.search.components.TokenSelectorBottomSheet +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: (UserAssetSearchEntry) -> 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/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/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index ac71423894..9ecb1ab5f4 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 @@ -25,7 +26,7 @@ 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.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkConverter import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConverter @@ -325,7 +326,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..5369759688 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, 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..b1d45c9693 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 @@ -31,8 +31,7 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase 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.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent 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,7 +62,6 @@ 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, @@ -147,7 +145,6 @@ internal class FeedComponentModel @Inject constructor( } }, analyticsEventHandler = analyticsEventHandler, - feedFeatureToggle = feedFeatureToggle, ) val currentState = stateController.value @@ -171,7 +168,7 @@ internal class FeedComponentModel @Inject constructor( analyticsEventHandler = analyticsEventHandler, ), UpdateEarnStateTransformer( - isEarnEnabled = feedFeatureToggle.isEarnBlockEnabled, + isEarnEnabled = true, onItemClick = ::handleEarnTokenClick, onRetryClick = ::fetchEarnData, earnResult = earnResult, @@ -206,7 +203,6 @@ internal class FeedComponentModel @Inject constructor( } private fun fetchEarnData() { - if (!feedFeatureToggle.isEarnBlockEnabled) return modelScope.launch(dispatchers.default) { stateController.update(UpdateEarnLoadingStateTransformer()) fetchTopEarnTokensUseCase() @@ -244,7 +240,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 +276,7 @@ internal class FeedComponentModel @Inject constructor( currentSortByType = SortByTypeUM.TopGainers, ), globalState = GlobalFeedState.Loading, - earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) { - EarnListUM.Loading - } else { - EarnListUM.Empty - }, + earnListUM = EarnListUM.Loading, ) } 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..e412504e4d 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 @@ -5,6 +5,7 @@ import arrow.core.Either import arrow.core.getOrElse import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.TangemSiteShareUrlBuilder +import com.tangem.domain.markets.PreselectedTokenDetailsSection 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 +22,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 @@ -95,6 +97,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( 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) @@ -297,6 +300,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( initialLoad() loadRelatedNews() + + if (params.shouldOpenExchanges) { + modelScope.launch { + val exchangesCount = params.exchangesCount + ?: currentTokenInfo.value?.exchangesAmount + ?: 0 + onListedOnClick(exchangesCount) + } + } } private fun initialLoad() { @@ -519,6 +531,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 +546,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 +775,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/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index 2273f437bb..8dc79c7ec2 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.search.model.UserAssetSearchEntry +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: UserAssetSearchEntry) { + 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: UserAssetSearchEntry) { + 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..9c8a912394 --- /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.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.features.feed.ui.search.state.TokenSelectorContentUM +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..86dda1eb30 --- /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.markets.toMarketsListItemPriceAnnotated +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.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.image.TangemIconUM +import com.tangem.core.ui.extensions.networkIconResId +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.search.model.UserAssetSearchEntry +import com.tangem.domain.search.model.UserAssetSearchItem +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +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: (UserAssetSearchEntry) -> 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: UserAssetSearchEntry): 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..40effc11de --- /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.core.decompose.di.ModelScoped +import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +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..a6c2729c0e --- /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.domain.appcurrency.model.AppCurrency +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.AccountHeaderData +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import kotlinx.collections.immutable.toImmutableList + +internal class BuildTokenSelectorSectionsTransformer( + private val entries: List, + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenSelected: (UserAssetSearchEntry) -> 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..b4719f582e --- /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.core.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.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.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +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: (UserAssetSearchEntry) -> Unit, +) : Converter { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + override fun convert(value: UserAssetSearchEntry): 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..f364f03d07 --- /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.features.feed.ui.search.state.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/ui/components/LayeringIcons.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt new file mode 100644 index 0000000000..1236344817 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt @@ -0,0 +1,99 @@ +package com.tangem.features.feed.ui.components + +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/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..79330be97f 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 @@ -106,6 +106,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( 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..b1d372de32 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 @@ -76,6 +76,8 @@ private fun LazyListScope.tokenMarketDetailsBodyV1( if (relatedNews.articles.isNotEmpty()) { relatedNews(relatedNews) + } else { + sectionStub(RelatedNews.SECTION_KEY) } aboutCoinHeader() @@ -91,6 +93,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) { @@ -244,6 +251,8 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa if (relatedNews.articles.isNotEmpty()) { relatedNews(relatedNews) + } else { + sectionStub(RelatedNews.SECTION_KEY) } if (state.securityScore != null) { @@ -324,7 +333,7 @@ private fun LazyListScope.loadingInfoBlocksV2() { } private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { - item("related-news") { + item(RelatedNews.SECTION_KEY) { Column( modifier = Modifier .fillMaxWidth() 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/list/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt index 933d5fe2a9..ab551d070a 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,37 +1,54 @@ 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.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.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() Column( @@ -48,17 +65,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 +80,67 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m } } +@Composable +internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) { + val background = LocalMainBottomSheetColor.current.value + val lazyListState = rememberLazyListState() + var chipsHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + 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( + 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() + }, + ) + } + } + } +} + @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/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 287b580b15..33b2589a72 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 @@ -31,17 +27,20 @@ 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 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.components.GroupedUserAssetItem +import com.tangem.features.feed.ui.search.components.SingleUserAssetItem 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 +53,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 +88,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 +117,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 +127,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 +154,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 +190,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 +285,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 +326,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 +383,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/components/BalanceColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt new file mode 100644 index 0000000000..552f07fcb3 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt @@ -0,0 +1,175 @@ +package com.tangem.features.feed.ui.search.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.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.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.utils.StringsSigns + +@Composable +internal 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/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt new file mode 100644 index 0000000000..11504f43d5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.ui.search.components + +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.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 +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.components.LayeringIcons +import com.tangem.features.feed.ui.search.state.UserAssetItemUM + +@Composable +internal 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/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt new file mode 100644 index 0000000000..dd388ce1c5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt @@ -0,0 +1,119 @@ +package com.tangem.features.feed.ui.search.components + +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 +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.UserAssetItemUM + +@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/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt new file mode 100644 index 0000000000..36196001f9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt @@ -0,0 +1,146 @@ +package com.tangem.features.feed.ui.search.components + +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 com.tangem.features.feed.ui.search.preview.TokenSelectorContentPreviewProvider +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.rememberHazeState + +@Composable +internal fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors2.surface.level2, + content = { content -> + TokenSelectorContent(content, config.onDismissRequest) + }, + ) +} + +@Composable +private fun TokenSelectorContent(content: TokenSelectorContentUM, onDismiss: () -> Unit) { + val hazeState = rememberHazeState() + var topBarHeight by remember { mutableStateOf(0.dp) } + + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier.hazeSourceTangem(state = hazeState, 1f), + contentPadding = PaddingValues( + start = TangemTheme.dimens2.x4, + end = TangemTheme.dimens2.x4, + top = topBarHeight, + bottom = TangemTheme.dimens2.x10, + ), + ) { + tokenSelectorSectionItems(content.sections) + } + 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/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt new file mode 100644 index 0000000000..1e96099720 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt @@ -0,0 +1,140 @@ +package com.tangem.features.feed.ui.search.components + +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 com.tangem.features.feed.ui.search.state.AccountHeaderData +import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import kotlinx.collections.immutable.ImmutableList + +internal 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/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..298dbfbcf1 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,10 @@ 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.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 +205,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 +261,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 +395,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/TokenSelectorContentPreviewProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt new file mode 100644 index 0000000000..2a1922d067 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt @@ -0,0 +1,122 @@ +package com.tangem.features.feed.ui.search.preview + +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 com.tangem.features.feed.ui.search.state.AccountHeaderData +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import kotlinx.collections.immutable.persistentListOf + +@Suppress("StringLiteralDuplication") +internal 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/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..2c9aecee9d --- /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.features.feed.ui.search.components.GroupedUserAssetItem +import com.tangem.features.feed.ui.search.components.SingleUserAssetItem +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.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..595a24e12b 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 @@ -3,6 +3,9 @@ package com.tangem.features.feed.ui.search.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList data class SearchUM( @@ -42,11 +45,57 @@ sealed interface 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 +@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/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt new file mode 100644 index 0000000000..3268d7d2c8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt @@ -0,0 +1,29 @@ +package com.tangem.features.feed.ui.search.state + +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 +internal data class TokenSelectorContentUM( + val sections: ImmutableList, +) : TangemBottomSheetConfigContent + +@Immutable +internal data class AccountHeaderData( + val accountName: TextReference, + val cryptoPortfolioIcon: CryptoPortfolioIcon, +) + +@Immutable +internal 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/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/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..5d588ab0d3 --- /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.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.features.feed.ui.search.state.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: (UserAssetSearchEntry) -> 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", + ): UserAssetSearchEntry { + 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..9663191563 --- /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.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.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: (UserAssetSearchEntry) -> 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"), + ), + ): UserAssetSearchEntry { + 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/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/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..5c8bc1a2ed 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) 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/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/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 4c6b3a90a3..4d66407671 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -12,6 +12,9 @@ android { } dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + /** Project - API */ implementation(projects.features.account.api) implementation(projects.features.onramp.api) 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/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/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/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 293f18bdc0..2daa88480b 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,16 @@ 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 + is PaymentAccountStatusValue.Locked -> 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 abdb3b8c6c..f3fbf4671e 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,6 +2,9 @@ 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 @@ -10,13 +13,8 @@ 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 +34,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 +59,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 @@ -100,17 +99,16 @@ import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_I 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, @@ -791,7 +793,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 +845,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 +874,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 +932,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 +951,7 @@ internal class StakingModel @Inject constructor( reduceAmountByDiff: BigDecimal, notification: Class, ) { - AmountReduceByStateTransformer( + val transformer = AmountReduceByStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, value = ReduceByData( @@ -957,6 +959,7 @@ internal class StakingModel @Inject constructor( reduceAmountByDiff = reduceAmountByDiff, ), ) + stateController.update(transformer) onNotificationCancel(notification) } @@ -1054,8 +1057,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 +1071,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(), @@ -1151,7 +1154,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, @@ -1305,7 +1308,7 @@ internal class StakingModel @Inject constructor( transformer = HideBalanceStateTransformer( isBalanceHidden = settings.isBalanceHidden, cryptoCurrencyStatus = cryptoCurrencyStatus, - appCurrency = appCurrency, + appCurrency = currentAppCurrency.value, ), ) } @@ -1314,17 +1317,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 } @@ -1381,7 +1373,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, @@ -1419,7 +1411,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, 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/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..c6c30d4bcb 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 @@ -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..1786ed1195 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 @@ -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..c933a896b6 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt @@ -0,0 +1,492 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +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 { + every { innerRouter.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onInitialInfoBannerClick() + + verify { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.WhatIsStaking }) + } + verify { + innerRouter.openUrl("https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/") + } + + model.onDestroy() + } + + @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 { + every { urlOpener.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onOpenLearnMoreAboutApproveClick() + + verify { urlOpener.openUrl("https://tangem.com/en/blog/post/give-revoke-permission/") } + + 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/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 b033d4f8cf..9df178e74b 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 @@ -126,6 +126,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) @@ -140,7 +141,6 @@ internal class SwapAmountModel @Inject constructor( ?: UserCountry.Other(Locale.getDefault().country) isShowBestRateAnimation = swapBestRateAnimationStore.getSyncOrNull() } - configAmountNavigation() subscribeOnCryptoCurrencyStatusFlow() subscribeOnAmountUpdateTriggerUpdates() observeChooseSelectToken() @@ -153,6 +153,7 @@ internal class SwapAmountModel @Inject constructor( fun onStart() { val isDelayFirst = params !is SwapAmountComponentParams.AmountBlockParams + configAmountNavigation() quoteTaskScheduler.scheduleTask( scope = modelScope, task = loadQuotesTask(isDelayFirst = isDelayFirst), @@ -163,6 +164,7 @@ internal class SwapAmountModel @Inject constructor( fun onStop() { quoteTaskScheduler.cancelTask() autoUpdateSubscriberJob?.cancel() + navigationJob?.cancel() } override fun onDestroy() { @@ -880,7 +882,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/transformers/SwapAmountSelectQuoteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt index 19a5e22946..7588c150a5 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 @@ -62,7 +62,8 @@ internal class SwapAmountSelectQuoteTransformer( ) return prevState.copy( - isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content, + isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content && + !hasInsufficientBalance(prevState, quoteContent, newPrimaryAmount), selectedQuote = quoteUM, isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true, primaryAmount = newPrimaryAmount, @@ -202,4 +203,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/preview/SwapAmountContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt index f15aca16d8..26beb5a846 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, 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..39b0819d33 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 @@ -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/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/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 35c8b24d02..7b506a8eda 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 { @@ -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/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..2d0f54f7b2 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,9 +2,7 @@ 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 @@ -17,8 +15,6 @@ interface SwapComponent : ComposableContentComponent { val isInitialReverseOrder: Boolean = false, val screenSource: String, val tangemPayInput: TangemPayInput? = null, - val preselectedToToken: CryptoCurrencyStatus? = null, - val preselectedAccount: Account? = null, ) { data class TangemPayInput( val cryptoAmount: BigDecimal, 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/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..dba1d47550 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 @@ -162,8 +162,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..b76d4c6e4c 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -38,6 +38,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/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 98165323aa..ae3c82c978 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 @@ -49,9 +49,9 @@ interface SwapInteractor { @Throws(IllegalStateException::class) suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, 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..9365e7893f 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 @@ -30,7 +30,8 @@ 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.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.network.Network @@ -133,11 +134,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( SingleAccountStatusListProducer.Params(userWalletId), - )?.accountStatuses.orEmpty().filterCryptoPortfolio() + )?.accountStatuses.orEmpty() val walletAccountCurrencyStatusesExceptInitial: Map> = walletAccountCurrencyStatuses.mapNotNull { accountStatus -> - val filteredCurrencies = accountStatus.flattenCurrencies().filterCurrencies(currency) + val filteredCurrencies = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies().filterCurrencies(currency) + is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) + } if (filteredCurrencies.isNotEmpty()) { accountStatus.account to filteredCurrencies @@ -154,7 +158,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( userWallet = userWallet, initialCurrency = LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.backendId, + network = currency.network.rawId, ), currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus -> accountStatus.value.map { it.currency } @@ -180,8 +184,18 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { + val currencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus + else -> return emptyList() + } + + return listOf(currencyStatus) + } + private fun List.filterCurrencies(currency: CryptoCurrency) = this.filter { status -> - val isDifferentCurrency = status.currency.network.backendId != currency.network.backendId || + val isDifferentCurrency = status.currency.network.rawId != currency.network.rawId || status.currency.getContractAddress() != currency.getContractAddress() val hasValidStatus = @@ -200,14 +214,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( ): CurrenciesGroup { val filteredPairs = leastPairs.filter { pair -> tokenInfoForFilter(pair).contractAddress == currency.getContractAddress() && - tokenInfoForFilter(pair).network == currency.network.backendId + tokenInfoForFilter(pair).network == currency.network.rawId } - val accountCurrencyList = cryptoCurrenciesList.mapNotNull { (accountEntry, currencyStatusList) -> - val cryptoPortfolio = accountEntry as? Account.CryptoPortfolio ?: return@mapNotNull null - + val accountCurrencyList = cryptoCurrenciesList.map { (accountEntry, currencyStatusList) -> AccountSwapAvailability( - account = cryptoPortfolio, + account = accountEntry, currencyList = currencyStatusList.map { currencyStatus -> val providers = findProvidersForPair( cryptoCurrencyStatuses = currencyStatus, @@ -243,7 +255,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return swapPairsLeastList.firstNotNullOfOrNull { pair -> val listTokenInfo = tokenInfoForAvailable(pair) - if (cryptoCurrencyStatuses.currency.network.backendId == listTokenInfo.network && + if (cryptoCurrencyStatuses.currency.network.rawId == listTokenInfo.network && cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress && isAvailableForSwap ) { @@ -313,9 +325,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod") override suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -339,8 +351,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( return providers.associateWith { createEmptyAmountState() } } val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) - val networkId = fromToken.currency.network.backendId + val isBalanceWithoutFeeEnough = when (fromAccount) { + is Account.Payment -> true + else -> isBalanceEnough(fromToken, amount, null) + } + val networkId = fromToken.currency.network.rawId return supervisorScope { providers.map { provider -> @@ -413,9 +428,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDex( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -434,9 +449,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val maybeQuotes = repository.findBestQuote( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.currency.decimals, @@ -496,9 +511,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDexSolana( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -508,9 +523,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val maybeQuotes = repository.findBestQuote( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.currency.decimals, @@ -551,9 +566,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageCex( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -653,7 +668,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( FeePaidCurrency.Coin -> { val nativeBalance = walletManagersFacade.getNativeTokenBalance( userWalletId = userWalletId, - networkId = fromTokenStatus.currency.network.backendId, + networkId = fromTokenStatus.currency.network.rawId, derivationPath = fromTokenStatus.currency.network.derivationPath.value, ) @@ -761,7 +776,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - val networkId = currencyToSend.currency.network.backendId + val networkId = currencyToSend.currency.network.rawId if (isSolana(networkId)) { onSwapSolanaDex( provider = swapProvider, @@ -883,7 +898,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.exchangeSent( userWallet = userWallet, txId = swapData.transaction.txId, - fromNetwork = currencyToSendStatus.currency.network.backendId, + fromNetwork = currencyToSendStatus.currency.network.rawId, fromAddress = fromAddress, payInAddress = payInAddress, txHash = txHash, @@ -949,10 +964,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( val exchangeData = repository.getExchangeData( userWallet = userWallet, fromContractAddress = currencyToSend.currency.getContractAddress(), - fromNetwork = currencyToSend.currency.network.backendId, + fromNetwork = currencyToSend.currency.network.rawId, toContractAddress = currencyToGet.currency.getContractAddress(), fromAddress = fromAddress, - toNetwork = currencyToGet.currency.network.backendId, + toNetwork = currencyToGet.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = currencyToGet.currency.decimals, @@ -997,7 +1012,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ), exchangeData = TangemPayWithdrawExchangeState( txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.backendId, + fromNetwork = currencyToSend.currency.network.rawId, fromAddress = networkAddress?.defaultAddress?.value.orEmpty(), payInAddress = exchangeData.transaction.txTo, payInExtraId = exchangeDataCex.txExtraId, @@ -1061,7 +1076,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.exchangeSent( userWallet = userWallet, txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.backendId, + fromNetwork = currencyToSend.currency.network.rawId, fromAddress = cexFromAddress, payInAddress = getPayoutAddress(txData), txHash = txHash, @@ -1203,10 +1218,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.getExchangeData( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = swapAmount.toStringWithRightOffset(), fromDecimals = swapAmount.decimals, toDecimals = toToken.currency.decimals, @@ -1216,7 +1231,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, expressOperationType = ExpressOperationType.SWAP, ).map { swapData -> - val networkId = fromToken.currency.network.backendId + val networkId = fromToken.currency.network.rawId val transaction = swapData.transaction as ExpressTransactionModel.DEX loadFeeForDex( @@ -1308,9 +1323,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1342,9 +1357,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val quotes = repository.findBestQuote( userWallet = 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, @@ -1402,9 +1417,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel: Either, amount: SwapAmount, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, networkId: String, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1516,7 +1531,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createSwapErrorWith( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, @@ -1706,9 +1721,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, @@ -1720,10 +1735,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( return repository.getExchangeData( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.currency.decimals, @@ -1850,7 +1865,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun produceDexSwapDataError( error: ExpressDataError, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, amount: SwapAmount, ): SwapState.SwapError { val rates = getQuotes(fromToken.currency.id) @@ -1877,7 +1892,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ): Either = either { val nativeBalance = walletManagersFacade.getNativeTokenBalance( userWalletId = userWalletId, - networkId = network.backendId, + networkId = network.rawId, derivationPath = fromToken.network.derivationPath.value, ) @@ -1947,9 +1962,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updateBalances( provider: SwapProvider, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, @@ -2015,7 +2030,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updatePermissionState( networkId: String, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, spenderAddress: String?, @@ -2040,14 +2055,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } // setting up amount for approve with given amount for swap [SwapApproveType.Limited] - val fromAddress = requireNotNull( + requireNotNull( fromTokenStatus.value.networkAddress?.defaultAddress?.value, - ) { "networkAddress cant be null" } + ) { "networkAddress cannot be null" } val allowanceInfo = getAllowanceInfoUseCase( userWalletId = userWalletId, cryptoCurrency = fromToken, - spenderAddress = requireNotNull(spenderAddress) { "spenderAddress cant be null" }, + spenderAddress = requireNotNull(spenderAddress) { "spenderAddress cannot be null" }, requiredAmount = swapAmount.value, ).getOrNull() @@ -2210,7 +2225,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") 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..d538156362 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 @@ -101,7 +101,7 @@ data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account.CryptoPortfolio?, + val account: Account?, ) data class RequestApproveStateData( 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..ca51e0e384 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -12,6 +12,9 @@ android { } dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + /** Core modules */ implementation(projects.core.analytics) implementation(projects.core.analytics.models) 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..09d6310350 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 @@ -12,7 +12,6 @@ import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss 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 @@ -22,7 +21,6 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme 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.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel @@ -56,10 +54,6 @@ internal class DefaultSwapComponent @AssistedInject constructor( context = child("chooseTokenComponent"), params = ChooseTokenComponent.Params( bridge = model.chooseTokenBridge, - settings = ChooseTokenComponent.Settings.SwapTo, - analyticsPayload = setOf( - ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), - ), ), ) } 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/choosetoken/api/ChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt new file mode 100644 index 0000000000..41c4423225 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt @@ -0,0 +1,131 @@ +package com.tangem.feature.swap.choosetoken.api + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.Account +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.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM +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.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +// todo swap move to common-features module +interface ChooseTokenBridge : ChooseTokenBridgeLegacy, 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 = false, + 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 + } + } +} + +// todo swap legacy api, remove +interface ChooseTokenBridgeLegacy : ChooseTokenBridgeInternal { + + val onTokenSelected: Channel + val onNewTokenAdded: Channel> + + val currenciesGroup: Flow + + fun onTokenSelected(result: ChooseTokenResultOld) { + onTokenSelected.trySend(result) + onSearchQuery(SearchQuery.Empty) + } + + fun onNewTokenAdded(addedToken: Pair) { + onNewTokenAdded.trySend(addedToken) + onSearchQuery(SearchQuery.Empty) + } + + fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) +} + +data class ChooseTokenResultOld( + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val account: Account, + val isSearched: Boolean, +) + +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/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 index e87799d55e..36bd0585db 100644 --- 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 @@ -2,103 +2,12 @@ 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/api/model/ChooseTokenPortfolioFullBlockUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt new file mode 100644 index 0000000000..4611fcdaab --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt @@ -0,0 +1,51 @@ +package com.tangem.feature.swap.choosetoken.api.model + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class ChooseTokenPortfolioFullBlockUM( + val walletList: WalletListUM, + val isBalanceHidden: Boolean, + 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 +sealed interface TokenListUMData { + + val tokensList: ImmutableList + val totalTokensCount: Int + + data class AccountList( + override val tokensList: ImmutableList, + override val totalTokensCount: Int, + ) : TokenListUMData + + data class TokenList( + override val tokensList: ImmutableList, + override val totalTokensCount: Int, + ) : TokenListUMData + + data object EmptyList : TokenListUMData { + override val tokensList: ImmutableList = persistentListOf() + override val totalTokensCount: Int = EMPTY_TOKENS_COUNT + } + + private companion object { + const val EMPTY_TOKENS_COUNT = 0 + } +} \ 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 index 870a5a7abe..39dfd2fdc1 100644 --- 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 @@ -1,10 +1,19 @@ package com.tangem.feature.swap.choosetoken.impl +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.UserWalletId import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge.Settings +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult +import com.tangem.feature.swap.choosetoken.api.ChooseTokenResultOld +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY +import com.tangem.feature.swap.choosetoken.impl.model.PortfolioFullBlockDelegate +import com.tangem.feature.swap.choosetoken.impl.model.PortfolioListBlockDelegate import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -15,32 +24,78 @@ 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 onTokenSelected: 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() + private val onSearchQuery: Channel = Channel() + override val searchQueryState: StateFlow = onSearchQuery.receiveAsFlow() .debounce(DEBOUNCE_SEARCH_DELAY) - .stateIn(modelScope, SharingStarted.Eagerly, initialValue = "") + .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 private val _currenciesGroupFlow = MutableStateFlow(null) override val currenciesGroup: Flow = _currenciesGroupFlow.filterNotNull() - override fun onSearchQuery(query: String) { + 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) + } + override fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) { _currenciesGroupFlow.update { currenciesGroup } } @AssistedFactory interface Factory : ChooseTokenBridge.Factory { - override fun create(modelScope: CoroutineScope): DefaultChooseTokenBridge + 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/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt index 62bd2f26e5..4b97a57ebe 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt @@ -15,15 +15,15 @@ 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.addtoportfolio.AddToPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject 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) @@ -54,9 +54,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, ), ) } 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 index a130a0eabf..296375932e 100644 --- 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 @@ -2,28 +2,41 @@ 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.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.filterCryptoPortfolio +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData 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 com.tangem.feature.swap.presentation.R 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 searchQuery: SearchQuery, + private val tokenFilter: (AccountStatus, CryptoCurrencyStatus) -> Boolean, + private val isShowPaymentAccount: Boolean, ) { private val isSearchingState: Boolean get() = searchQuery.isSearchingState @@ -33,6 +46,24 @@ internal class ChooseTokenListItemConverter( 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) }, @@ -42,6 +73,7 @@ internal class ChooseTokenListItemConverter( return when (params) { is TokenConverterParams.Account -> convertAccountList(params) is TokenConverterParams.Wallet -> convertTokenList( + account = params.mainAccount, tokenConverter = tokenStatusConverter(params.mainAccount), tokenListParam = params.tokenList, ) @@ -51,16 +83,19 @@ internal class ChooseTokenListItemConverter( private fun convertAccountList(params: TokenConverterParams.Account): TokenListUMData { val accountList = params.accountList val accountItems = accountList.accountStatuses - .filterCryptoPortfolio() - .map { accountStatus -> accountStatus.toPortfolioItem(params) } + .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 } - val accountsList = accountItems.toPersistentList() return TokenListUMData.AccountList( - tokensList = accountsList, - totalTokensCount = accountsList.size, + tokensList = accountItems.toPersistentList(), + totalTokensCount = accountItems.sumOf { portfolio -> portfolio.tokensItemsList.size }, ) } @@ -71,21 +106,20 @@ internal class ChooseTokenListItemConverter( 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) - } + 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) + val tokensListState = convertTokenList(tokenConverter, tokenList, this) val items = tokensListState.tokensList return TokensListPortfolioItemConverter( tokenItemUM = accountItem, @@ -95,41 +129,38 @@ internal class ChooseTokenListItemConverter( ).convert(Unit) } - private fun convertTokenList(tokenConverter: TokenItemStateConverter, tokenListParam: TokenList): TokenListUMData { - val tokenList = if (isSearchingState) filterByQuery(tokenListParam) else tokenListParam - - return when (tokenList) { + 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 -> 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, - ) - } + 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 filterByQuery(tokenList: TokenList): TokenList { - fun List.filterByQuery(): List = filter { currency -> - currency.currency.name.contains(searchQuery, ignoreCase = true) || - currency.currency.symbol.contains(searchQuery, ignoreCase = true) - } + 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.filterByQuery() + 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.filterByQuery() + val filteredCurrencies = group.currencies.filterCurrencies(account) group.copy(currencies = filteredCurrencies) } .filter { group -> group.currencies.isNotEmpty() } @@ -137,4 +168,63 @@ internal class ChooseTokenListItemConverter( } } } + + 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, + -> return null + is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus + is PaymentAccountStatusValue.Locked -> 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/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index 2eb003236d..f03c6bc306 100644 --- 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 @@ -5,47 +5,28 @@ 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.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState 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() { @@ -53,61 +34,71 @@ internal class ChooseTokenModel @Inject constructor( private val params = paramsContainer.require() private val bridge: ChooseTokenBridge = params.bridge - private val searchQueryState: StateFlow = bridge.searchQueryState + 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 + screensSourcesName = bridge.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) { + private val marketsStateFlow: Flow = if (bridge.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 -> + flow2 = bridge.fullPortfolioBlock, + flow3 = marketsStateFlow, + transform = { initial, content, marketBlock -> ChooseTokenFullUM( initialUM = initial, - contentUM = content, + portfolioBlock = content, + marketsBlock = marketBlock, ) }, ).stateIn( scope = modelScope, started = SharingStarted.Eagerly, - initialValue = ChooseTokenFullUM(initialState.value, contentState.value), + 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 newToken = addedResult.addedCurrency.currency to isSearched + bridge.onNewTokenAdded(newToken) + val chooseTokenResult = ChooseTokenResult( + currency = addedResult.addedCurrency, + account = addedResult.account, + wallet = addedResult.wallet, + analyticsPayload = setOf(isSearched), + ) + bridge.onCurrencyChosen(chooseTokenResult) + marketBlockDelegate.addToPortfolioSlot.dismiss() + } + .launchIn(modelScope) + } + @Suppress("UnusedPrivateMember") private fun combineUIOld(): StateFlow = combine( flow = bridge.currenciesGroup, @@ -121,10 +112,13 @@ internal class ChooseTokenModel @Inject constructor( TokensDataConverter( onSearchEntered = { query -> bridge.onSearchQuery(query) }, - onTokenClick = { tokenId -> - val selected = tokenId to ChooseTokenAnalyticsPayload - .IsSearched(isSearchingState) - bridge.onTokenSelected(selected) + onTokenClick = { account, cryptoCurrencyStatus -> + val result = ChooseTokenResultOld( + account = account, + cryptoCurrencyStatus = cryptoCurrencyStatus, + isSearched = searchQueryState.isSearchingState, + ) + bridge.onTokenSelected(result) }, onAccountClick = { account -> expandedAccountsFlow.update { expandedList -> @@ -145,94 +139,6 @@ internal class ChooseTokenModel @Inject constructor( .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() } @@ -251,7 +157,7 @@ internal class ChooseTokenModel @Inject constructor( ) private fun getInitState() = ChooseTokenInitialUM( - screenTitle = params.settings.title, + screenTitle = bridge.settings.title, onCloseClick = ::onBackClicked, searchBar = getInitialSearchBar(), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt index d83753ba54..e9c9ff93d4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt @@ -14,42 +14,44 @@ 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.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState 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.addtoportfolio.AddToPortfolioManager 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 @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 +76,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 +85,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 +159,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 +179,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/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt new file mode 100644 index 0000000000..c19b107c11 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt @@ -0,0 +1,111 @@ +package com.tangem.feature.swap.choosetoken.impl.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.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.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.feature.swap.choosetoken.api.model.WalletListUM +import com.tangem.feature.swap.choosetoken.api.model.WalletTabUM +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 fullPortfolioBlockFlow = combine( + flow = getWalletsUseCase.invokeAsMap(), + 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/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt index 64558e8d15..ccbbf72f16 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt @@ -3,7 +3,7 @@ package com.tangem.feature.swap.choosetoken.impl.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.ChooseTokenAnalyticsPayload +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.choosetoken.impl.converter.ChooseTokenListItemConverter -import com.tangem.feature.swap.models.TokenListUMData 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/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt index 626a24fe4e..14b9ef03d6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt @@ -1,15 +1,23 @@ package com.tangem.feature.swap.choosetoken.impl.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,15 +38,16 @@ 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.lazyListItemPosition import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection -import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData +import com.tangem.feature.swap.choosetoken.api.model.WalletListUM +import com.tangem.feature.swap.choosetoken.api.model.WalletTabUM import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.ui.market.swapMarketsListItems @@ -54,6 +59,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( @@ -93,7 +118,6 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { modifier = modifier .fillMaxSize() .nestedScroll(nestedScrollConnection), - horizontalAlignment = Alignment.CenterHorizontally, state = lazyListState, contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { @@ -107,22 +131,27 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { 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 +212,59 @@ 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, + ) + + if (state.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 = state.count.resolveReference(), + color = countTextColor, + style = TangemTheme.typography.caption1, ) } } @@ -253,6 +315,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 +435,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 +479,18 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider 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..333bb5087c 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.CurrencyIconState import com.tangem.core.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) }, ) } 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 index 64ba7cf5b0..20fa223b66 100644 --- 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 @@ -5,9 +5,10 @@ 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.domain.models.currency.CryptoCurrencyStatus +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData 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 @@ -16,10 +17,10 @@ import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TokensDataConverter( - private val onSearchEntered: (String) -> Unit, - onTokenClick: (String) -> Unit, - onAccountClick: (Account.CryptoPortfolio) -> Unit, + onTokenClick: (Account, CryptoCurrencyStatus) -> Unit, + onAccountClick: (Account) -> Unit, private val expandedAccounts: Map, + private val onSearchEntered: (String) -> Unit, private val tokensDataState: CurrenciesGroup, private val isBalanceHidden: Boolean, private val isAccountsMode: Boolean, @@ -52,7 +53,7 @@ internal class TokensDataConverter( } else { val tokensList = accountList.flatMap { (_, currencyList) -> currencyList.asSequence().map { accountSwapCurrency -> - accountListItemConverter.createAvailableItemConverter() + accountListItemConverter.createAvailableItemConverter(accountSwapCurrency.account) .convert(accountSwapCurrency.cryptoCurrencyStatus) }.map(TokensListItemUM::Token).toPersistentList() }.toPersistentList() 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/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index e0328dd545..074fe771f4 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 @@ -25,7 +25,6 @@ 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.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* import com.tangem.core.ui.message.DialogMessage @@ -33,7 +32,6 @@ 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 @@ -49,12 +47,14 @@ 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.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 @@ -71,6 +71,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet 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.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter @@ -144,8 +145,8 @@ internal class SwapModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val appsFlyerStore: AppsFlyerStore, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, private val messageSender: UiMessageSender, + private val paymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, private val allowPermissionsHandler: AllowPermissionsHandler, chooseTokenBridgeFactory: ChooseTokenBridge.Factory, giveApprovalFeatureToggles: GiveApprovalFeatureToggles, @@ -166,8 +167,7 @@ internal class SwapModel @Inject constructor( } private val swapInteractor = swapInteractorFactory.create(userWalletId) - val isHoldToConfirmEnabled: Boolean = - holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWallet.isHotWallet + val isHoldToConfirmEnabled: Boolean = userWallet.isHotWallet private lateinit var initialFromStatus: CryptoCurrencyStatus private var initialToStatus: CryptoCurrencyStatus? = null @@ -177,7 +177,13 @@ internal class SwapModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() - val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(modelScope) + val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings.SwapTo, + analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), + ), + ) private val stateBuilder = StateBuilder( userWalletProvider = Provider { userWallet }, @@ -186,7 +192,6 @@ internal class SwapModel @Inject constructor( appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, - holdToConfirmButtonFeatureToggles = holdToConfirmButtonFeatureToggles, ) private val inputNumberFormatter = @@ -222,8 +227,10 @@ internal class SwapModel @Inject constructor( private val swapRouter: SwapRouter = SwapRouter(router = router) private var userCountry: UserCountry? = null - private var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null - private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null + private var fromAccount: Account? = null + private var toAccount: Account? = null + private var fromAccountStatus: CryptoCurrencyStatus? = null + private var toAccountStatus: CryptoCurrencyStatus? = null /** * If user came from Tangem Pay -> fromAccountCurrencyStatus == null @@ -283,7 +290,7 @@ internal class SwapModel @Inject constructor( init { chooseTokenBridge.searchQueryState - .onEach { query -> onSearchEntered(query) } + .onEach { query -> onSearchEntered(query.value) } .launchIn(modelScope) chooseTokenBridge.onNewTokenAdded.receiveAsFlow() @@ -293,8 +300,12 @@ internal class SwapModel @Inject constructor( .launchIn(modelScope) chooseTokenBridge.onTokenSelected.receiveAsFlow() - .onEach { (addedToken, isSearched) -> - onTokenSelect(addedToken, isSearched.value) + .onEach { result -> + onTokenSelect( + account = result.account, + cryptoCurrencyStatus = result.cryptoCurrencyStatus, + isSearched = result.isSearched, + ) } .launchIn(modelScope) @@ -329,22 +340,31 @@ internal class SwapModel @Inject constructor( userWalletId = userWalletId, currency = initialCurrencyFrom, ).getOrNull() + val fromPaymentAccountStatus = + paymentAccountCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom).getOrNull() val toAccountStatus = initialCurrencyTo?.let { currencyTo -> getAccountCurrencyStatusUseCase.invokeSync( userWalletId = userWalletId, currency = currencyTo, ).getOrNull() } + val toPaymentAccountStatus = initialCurrencyTo?.let { currencyTo -> + paymentAccountCryptoCurrencyStatusUseCase(userWalletId, currencyTo).getOrNull() + } + val fromAccount = fromAccountStatus?.account ?: fromPaymentAccountStatus?.first + val fromStatus = fromAccountStatus?.status ?: fromPaymentAccountStatus?.second - if (fromAccountStatus == null) { + if (fromAccount != null && fromStatus != null) { + this@SwapModel.fromAccount = fromAccount + this@SwapModel.fromAccountStatus = fromStatus + this@SwapModel.toAccount = toAccountStatus?.account ?: toPaymentAccountStatus?.first + this@SwapModel.toAccountStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second + this@SwapModel.initialFromStatus = fromStatus + this@SwapModel.initialToStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second + initTokens(isInitiallyReversed) + } else { showAlert() swapRouter.back() - } else { - fromAccountCurrencyStatus = fromAccountStatus - toAccountCurrencyStatus = toAccountStatus - initialFromStatus = fromAccountStatus.status - initialToStatus = toAccountStatus?.status - initTokens(isInitiallyReversed) } } else { val fromStatus = getFromStatus() @@ -412,7 +432,7 @@ internal class SwapModel @Inject constructor( updateTokensState(state) val (selectedCurrency, selectedAccount) = run { - var selectedAccountCurrency = toAccountCurrencyStatus + var selectedAccountCurrency = toAccountStatus if (selectedAccountCurrency == null) { val amountSwapCurrency = swapInteractor.getInitialCurrencyToSwap( @@ -422,14 +442,11 @@ internal class SwapModel @Inject constructor( ) if (amountSwapCurrency != null) { - selectedAccountCurrency = AccountCryptoCurrencyStatus( - account = amountSwapCurrency.account, - status = amountSwapCurrency.cryptoCurrencyStatus, - ) + selectedAccountCurrency = amountSwapCurrency.cryptoCurrencyStatus } } - selectedAccountCurrency?.status to selectedAccountCurrency?.account + selectedAccountCurrency to toAccount } val isApplied = applyInitialTokenChoice( @@ -541,7 +558,7 @@ internal class SwapModel @Inject constructor( private fun applyInitialTokenChoice( state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?, - selectedAccount: Account.CryptoPortfolio?, + selectedAccount: Account?, isReverseFromTo: Boolean, ): Boolean { // exceptional case @@ -562,9 +579,9 @@ internal class SwapModel @Inject constructor( } val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { if (isOrderReversed.value) { - selectedAccount to requireNotNull(fromAccountCurrencyStatus).account + selectedAccount to fromAccount } else { - requireNotNull(fromAccountCurrencyStatus).account to selectedAccount + fromAccount to selectedAccount } } else { null to null @@ -607,9 +624,9 @@ internal class SwapModel @Inject constructor( private fun startLoadingQuotes( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -680,9 +697,9 @@ internal class SwapModel @Inject constructor( private fun loadQuotesTask( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -784,7 +801,7 @@ internal class SwapModel @Inject constructor( selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - hideFee = tangemPayInput?.isWithdrawal == true, + hideFee = isTangemPayWithdrawal(), ) } @@ -873,11 +890,11 @@ internal class SwapModel @Inject constructor( private fun sendErrorAnalyticsEvent(error: ExpressDataError, provider: SwapProvider) { val receiveToken = dataState.toCryptoCurrency?.currency?.let { currency -> - "${currency.network.backendId}:${currency.symbol}" + "${currency.network.rawId}:${currency.symbol}" } analyticsErrorEventHandler.sendErrorEvent( SwapEvents.NoticeProviderError( - sendToken = "${initialCurrencyFrom.network.backendId}:${initialCurrencyFrom.symbol}", + sendToken = "${initialCurrencyFrom.network.rawId}:${initialCurrencyFrom.symbol}", receiveToken = receiveToken.orEmpty(), provider = provider, errorCode = error.code, @@ -978,8 +995,9 @@ internal class SwapModel @Inject constructor( } val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) 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 { @@ -1001,7 +1019,7 @@ internal class SwapModel @Inject constructor( includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = fee, expressOperationType = ExpressOperationType.SWAP, - isTangemPayWithdrawal = tangemPayInput?.isWithdrawal == true, + isTangemPayWithdrawal = isTangemPayWithdrawal, ) }.onSuccess { swapTransactionState -> when (swapTransactionState) { @@ -1166,7 +1184,7 @@ internal class SwapModel @Inject constructor( } runCatching(dispatchers.io) { swapInteractor.givePermissionToSwap( - networkId = fromToken.network.backendId, + networkId = fromToken.network.rawId, permissionOptions = PermissionOptions( approveData = approveDataModel, forTokenContractAddress = (fromToken as? CryptoCurrency.Token)?.contractAddress.orEmpty(), @@ -1259,11 +1277,12 @@ internal class SwapModel @Inject constructor( } @Suppress("LongMethod") - private fun onTokenSelect(id: String, isSearched: Boolean) { + private fun onTokenSelect(account: Account, cryptoCurrencyStatus: CryptoCurrencyStatus, isSearched: Boolean) { val tokens = dataState.tokensDataState ?: return - val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id) + val foundToken = cryptoCurrencyStatus + val foundAccount = account - foundToken?.currency?.symbol?.let { symbol -> + foundToken.currency.symbol.let { symbol -> analyticsEventHandler.send( SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol), ) @@ -1277,108 +1296,90 @@ internal class SwapModel @Inject constructor( ) } - 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 fromToken: CryptoCurrencyStatus + val fromAccount: Account? + val toToken: CryptoCurrencyStatus + val toAccount: Account? + if (isOrderReversed.value) { + fromToken = foundToken + fromAccount = foundAccount + toToken = initialFromStatus + toAccount = this.fromAccount - val newToken = fromToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = true, - ) - } else { - fromTokenBalanceJobHolder.cancel() - } + val newToken = fromToken.currency as? CryptoCurrency.Coin + if (newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = true, + ) } 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() - } + fromTokenBalanceJobHolder.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 + fromToken = initialFromStatus + fromAccount = this.fromAccount + toToken = foundToken + toAccount = foundAccount + + val newToken = toToken.currency as? CryptoCurrency.Coin + if (newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = false, + ) + } else { + toTokenBalanceJobHolder.cancel() } } - return accountCryptoCurrencyStatus?.cryptoCurrencyStatus to accountCryptoCurrencyStatus?.account + + 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) } @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -1930,8 +1931,8 @@ internal class SwapModel @Inject constructor( state: TokensDataStateExpress, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, ): Boolean { val selectedCurrency = if (isOrderReversed.value) fromToken else toToken if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false @@ -1979,8 +1980,12 @@ internal class SwapModel @Inject constructor( } } + private fun isTangemPayWithdrawal(): Boolean { + return tangemPayInput?.isWithdrawal == true || dataState.fromAccount is Account.Payment + } + private fun List.filterForTangemPayWithdrawal(): List { - return if (tangemPayInput?.isWithdrawal == true) { + return if (isTangemPayWithdrawal()) { filter { it.type == ExchangeProviderType.CEX } } else { this @@ -2117,8 +2122,7 @@ internal class SwapModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = network.rawId, - derivationPath = network.derivationPath.value, + networkId = network.id, destinationAddress = transaction?.txTo.orEmpty(), tokenSymbol = fromCurrencyStatus.currency.symbol, amount = dataState.amount.orEmpty(), 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..1c031215dd 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 @@ -15,8 +15,8 @@ data class SwapProcessDataState( val fromCryptoCurrency: CryptoCurrencyStatus? = null, val toCryptoCurrency: CryptoCurrencyStatus? = null, val feePaidCryptoCurrency: CryptoCurrencyStatus? = null, - val fromAccount: Account.CryptoPortfolio? = null, - val toAccount: Account.CryptoPortfolio? = null, + val fromAccount: Account? = null, + val toAccount: Account? = null, // Amount from input val amount: String? = null, val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index 950ef764ad..5582d69682 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -1,10 +1,7 @@ package com.tangem.feature.swap.models -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.models.market.state.SwapMarketState -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf internal data class SwapSelectTokenStateHolder( val marketsState: SwapMarketState, @@ -14,32 +11,6 @@ internal data class SwapSelectTokenStateHolder( val onSearchEntered: (String) -> Unit, ) -@Immutable -internal sealed interface TokenListUMData { - - val tokensList: ImmutableList - val totalTokensCount: Int - - data class AccountList( - override val tokensList: ImmutableList, - override val totalTokensCount: Int, - ) : TokenListUMData - - data class TokenList( - override val tokensList: ImmutableList, - override val totalTokensCount: Int, - ) : TokenListUMData - - data object EmptyList : TokenListUMData { - override val tokensList: ImmutableList = persistentListOf() - override val totalTokensCount: Int = EMPTY_TOKENS_COUNT - } - - private companion object { - const val EMPTY_TOKENS_COUNT = 0 - } -} - internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean get() = tokensListData.tokensList.isEmpty() && isAfterSearch && 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..dfb24bc959 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,13 +3,13 @@ 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.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.* @@ -58,11 +58,9 @@ internal class StateBuilder( private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, - holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) { - private val isHoldToConfirmEnabled: Boolean = - holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWalletProvider().isHotWallet + private val isHoldToConfirmEnabled: Boolean = userWalletProvider().isHotWallet private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -89,7 +87,7 @@ internal class StateBuilder( token = null, tokenIconUrl = initialCurrencyFrom.iconUrl, tokenCurrency = initialCurrencyFrom.symbol, - coinId = initialCurrencyFrom.network.backendId, + coinId = initialCurrencyFrom.network.rawId, canSelectAnotherToken = false, isNotNativeToken = initialCurrencyFrom is CryptoCurrency.Token, balance = "", @@ -107,7 +105,7 @@ internal class StateBuilder( balance = "", isNotNativeToken = initialCurrencyTo is CryptoCurrency.Token, networkIconRes = initialCurrencyTo?.let { getActiveIconRes(it.network.rawId) }, - coinId = initialCurrencyTo?.network?.backendId, + coinId = initialCurrencyTo?.network?.rawId, isBalanceHidden = true, ), fee = FeeItemState.Empty, @@ -144,7 +142,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = fromToken, tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.backendId, + coinId = fromToken.currency.network.rawId, isNotNativeToken = fromToken.currency is CryptoCurrency.Token, tokenCurrency = fromToken.currency.symbol, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, @@ -177,8 +175,8 @@ internal class StateBuilder( uiStateHolder: SwapStateHolder, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, mainTokenId: String, ): SwapStateHolder { val canSelectSendToken = mainTokenId != fromToken.currency.id.value @@ -195,7 +193,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = fromToken, tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.backendId, + coinId = fromToken.currency.network.rawId, isNotNativeToken = fromToken.currency is CryptoCurrency.Token, tokenCurrency = fromToken.currency.symbol, canSelectAnotherToken = canSelectSendToken, @@ -213,7 +211,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = toToken, tokenIconUrl = toToken.currency.iconUrl, - coinId = toToken.currency.network.backendId, + coinId = toToken.currency.network.rawId, isNotNativeToken = toToken.currency is CryptoCurrency.Token, tokenCurrency = toToken.currency.symbol, canSelectAnotherToken = canSelectReceiveToken, @@ -241,8 +239,8 @@ internal class StateBuilder( fromToken: CryptoCurrency, toToken: CryptoCurrency, mainTokenId: String, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, ): SwapStateHolder { val canSelectSendToken = mainTokenId != fromToken.id.value val canSelectReceiveToken = mainTokenId != toToken.id.value @@ -265,7 +263,7 @@ internal class StateBuilder( token = uiStateHolder.sendCardData.token, tokenIconUrl = fromToken.iconUrl, tokenCurrency = fromToken.symbol, - coinId = fromToken.network.backendId, + coinId = fromToken.network.rawId, isNotNativeToken = fromToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectSendToken, balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", @@ -281,7 +279,7 @@ internal class StateBuilder( token = uiStateHolder.receiveCardData.token, tokenIconUrl = toToken.iconUrl, tokenCurrency = toToken.symbol, - coinId = toToken.network.backendId, + coinId = toToken.network.rawId, isNotNativeToken = toToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectReceiveToken, balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", @@ -373,7 +371,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), token = fromCurrencyStatus, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = fromCurrencyStatus.currency.network.backendId, + coinId = fromCurrencyStatus.currency.network.rawId, isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, @@ -414,7 +412,7 @@ internal class StateBuilder( }, token = toCurrencyStatus, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toCurrencyStatus.currency.network.backendId, + coinId = toCurrencyStatus.currency.network.rawId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, @@ -502,7 +500,7 @@ internal class StateBuilder( swapProvider: SwapProvider, fromToken: TokenSwapInfo, toToken: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, isReverseSwapPossible: Boolean, @@ -535,7 +533,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = toToken, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toToken.currency.network.backendId, + coinId = toToken.currency.network.rawId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, @@ -619,7 +617,7 @@ internal class StateBuilder( emptyAmountState: SwapState.EmptyAmountState, fromTokenStatus: CryptoCurrencyStatus, toTokenStatus: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -694,7 +692,7 @@ internal class StateBuilder( amountFormatted: String, amountRaw: String, fromToken: CryptoCurrency, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState @@ -1331,30 +1329,37 @@ internal class StateBuilder( return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId) } - private fun getFromCardAccountTitle(fromAccount: Account.CryptoPortfolio?): AccountTitleUM { + private fun getFromCardAccountTitle(fromAccount: Account?): AccountTitleUM { return if (fromAccount != null && isAccountsModeProvider()) { AccountTitleUM.Account( prefixText = resourceReference(R.string.common_from), name = fromAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(fromAccount.icon), + icon = fromAccount.toIconUM(), ) } else { AccountTitleUM.Text(resourceReference(R.string.swapping_from_title)) } } - private fun getToCardAccountTitle(toAccount: Account.CryptoPortfolio?): AccountTitleUM { + private fun getToCardAccountTitle(toAccount: Account?): AccountTitleUM { return if (toAccount != null && isAccountsModeProvider()) { AccountTitleUM.Account( prefixText = resourceReference(R.string.common_to), name = toAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(toAccount.icon), + icon = toAccount.toIconUM(), ) } 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 { 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 index 6758ca18b7..4ea3cff4f0 100644 --- 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 @@ -39,8 +39,8 @@ 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.choosetoken.api.model.TokenListUMData 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 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 index bb19ee30a7..94da0ebd00 100644 --- 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 @@ -13,8 +13,8 @@ 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.choosetoken.api.model.TokenListUMData 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 diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt new file mode 100644 index 0000000000..9ec8ddecd1 --- /dev/null +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.tangempay.components + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig + +interface TangemPayCardPageComponent : ComposableContentComponent { + data class Params( + val userWalletId: UserWalletId, + val config: TangemPayDetailsConfig, + ) + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt new file mode 100644 index 0000000000..de2c4bf4a2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt @@ -0,0 +1,104 @@ +package com.tangem.features.tangempay.components + +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.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tokenreceive.TokenReceiveComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: TangemPayCardPageComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, +) : AppComponentContext by appComponentContext, TangemPayCardPageComponent { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val childStack = childStack( + key = "tangemPayCardPageInnerStack", + source = stackNavigation, + serializer = TangemPayDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayDetailsInnerRoute.Details, + childFactory = ::screenChild, + ) + + @Suppress("ReusedModifierInstance") + @Composable + override fun Content(modifier: Modifier) { + val childStack by childStack.subscribeAsState() + Children( + stack = childStack, + ) { child -> + child.instance.Content(modifier = modifier) + } + } + + private fun screenChild( + config: TangemPayDetailsInnerRoute, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + TangemPayDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + tokenReceiveComponentFactory = tokenReceiveComponentFactory, + ) + TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + } + + private fun onChildBack() { + if (childStack.value.backStack.isEmpty()) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : TangemPayCardPageComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayCardPageComponent.Params, + ): DefaultTangemPayCardPageComponent + } +} \ 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..35ea8d9089 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 @@ -75,6 +75,13 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, ) + TangemPayDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) } private fun onChildBack() { 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/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt new file mode 100644 index 0000000000..039bcab003 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -0,0 +1,114 @@ +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.TangemPayDetailsNavigation +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 = TangemPayDetailsNavigation.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: TangemPayDetailsNavigation, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val context = childByContext(componentContext) + return when (navigation) { + is TangemPayDetailsNavigation.ViewPinCode -> TangemPayViewPinComponent( + appComponentContext = context, + params = TangemPayViewPinComponent.Params( + walletId = navigation.userWalletId, + cardId = navigation.cardId, + listener = model, + ), + ) + is TangemPayDetailsNavigation.ReissueCard -> TangemPayReissueCardComponent( + appComponentContext = context, + params = TangemPayReissueCardComponent.Params( + listener = model, + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ), + ) + is TangemPayDetailsNavigation.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 TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create( + context = context, + params = TokenReceiveComponent.Params( + config = navigation.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) + else -> error("Unsupported bottom sheet navigation: $navigation") + } + } +} \ 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..b59577ebe8 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 @@ -51,7 +51,10 @@ internal class TangemPayDetailsComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params(params = params), + params = TangemPayCardDetailsBlockComponent.Params( + params = params, + isDisplayCardNameEnabled = false, + ), ) private val expressTransactionsComponent by lazy { @@ -127,6 +130,7 @@ internal class TangemPayDetailsComponent( listener = model, ), ) + else -> error("Unsupported bottom sheet navigation: $navigation") } } } \ 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/TangemPayDetailsFeatureModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt index de293a174d..df71114f7c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt @@ -1,6 +1,8 @@ package com.tangem.features.tangempay.di +import com.tangem.features.tangempay.components.DefaultTangemPayCardPageComponent import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayCardPageComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener @@ -20,6 +22,12 @@ internal interface TangemPayDetailsFeatureModule { factory: DefaultTangemPayDetailsContainerComponent.Factory, ): TangemPayDetailsContainerComponent.Factory + @Binds + @Singleton + fun bindTangemPayCardPageComponentFactory( + factory: DefaultTangemPayCardPageComponent.Factory, + ): TangemPayCardPageComponent.Factory + @Binds @Singleton fun bindCardDetailsEventListener(impl: DefaultCardDetailsEventListener): CardDetailsEventListener 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..d9b0f67529 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,13 @@ 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.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 +62,19 @@ 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 } \ 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/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt new file mode 100644 index 0000000000..93bda7eba7 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -0,0 +1,37 @@ +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 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, + ) = TangemPayCardPageUM( + addToWalletBlockState = addToWalletBlockState, + settings = settings, + onBackClick = {}, + isReissueInProgress = isReissueInProgress, + ) + } +} + +@Immutable +internal data class TangemPayCardPageSetting( + val title: TextReference, + val onSettingClick: () -> Unit, +) \ 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..fdc5357eae 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 @@ -32,4 +32,7 @@ internal sealed class TangemPayDetailsNavigation { val userWalletId: UserWalletId, val cardId: String, ) : TangemPayDetailsNavigation() + + @Serializable + data object ReissueCard : TangemPayDetailsNavigation() } \ 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 f67ed6b7b0..7eb7b01df6 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 @@ -30,8 +30,28 @@ 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 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/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..13025f10a1 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,13 +14,13 @@ 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 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.model.transformers.* +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -42,12 +43,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 +130,10 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) } + private fun startEditingDisplayName() { + router.push(TangemPayDetailsInnerRoute.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..681148be62 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -0,0 +1,292 @@ +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.message.SnackbarMessage +import com.tangem.domain.models.TokenReceiveConfig +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.AddToWalletBlockState +import com.tangem.features.tangempay.entity.TangemPayCardPageSetting +import com.tangem.features.tangempay.entity.TangemPayCardPageUM +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +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.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class TangemPayCardPageModel @Inject constructor( + paramsContainer: ParamsContainer, + 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 var currentFrozenState: TangemPayCardFrozenState = params.config.cardFrozenState + + private val addToWalletBannerJobHolder = JobHolder() + private val addFundsJobHolder = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayCardPageUM( + onBackClick = router::pop, + settings = persistentListOf( + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_change_pin), + onSettingClick = ::onClickChangePIN, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + onSettingClick = ::onClickFreezeOrUnfreezeCard, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_reissue_card), + onSettingClick = ::onClickReissueCard, + ), + ), + ), + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + init { + // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed + fetchAddToWalletBanner() + subscribeToCardFrozenState() + } + + private fun onClickChangePIN() { + if (!params.config.isPinSet) { + router.push(TangemPayDetailsInnerRoute.ChangePIN) + } else { + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.ViewPinCode( + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ), + ) + } + } + + private fun onClickFreezeOrUnfreezeCard() { + when (currentFrozenState) { + TangemPayCardFrozenState.Frozen -> uiMessageSender.send( + TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard), + ) + else -> uiMessageSender.send( + TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard), + ) + } + } + + private fun onClickReissueCard() { + analytics.send(TangemPayAnalyticsEvents.ReplaceCardClicked()) + bottomSheetNavigation.activate(TangemPayDetailsNavigation.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( + TangemPayDetailsNavigation.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(TangemPayDetailsNavigation.Receive(config)) + } + + override fun onClickSwap(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + router.push( + AppRoute.Swap( + currencyFrom = data.currency, + userWalletId = data.walletId, + isInitialReverseOrder = true, + 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 subscribeToCardFrozenState() { + cardDetailsRepository + .cardFrozenState(params.config.cardId) + .onEach { state -> currentFrozenState = state } + .launchIn(modelScope) + } + + 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(TangemPayDetailsInnerRoute.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(TangemPayDetailsInnerRoute.ChangePIN) + } + + override fun onDismissViewPin() { + bottomSheetNavigation.dismiss() + } + + private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) { + when (orderStatus) { + OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED, OrderStatus.UNKNOWN -> { + 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/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt new file mode 100644 index 0000000000..a559c7a3d9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -0,0 +1,92 @@ +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(params.userWalletId, 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/navigation/TangemPayDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt index 7bc24c9979..168ea88da5 100644 --- 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 @@ -16,4 +16,7 @@ internal sealed class TangemPayDetailsInnerRoute : Route { @Serializable data object AddToWallet : TangemPayDetailsInnerRoute() + + @Serializable + data object EditCardDisplayName : 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..b2509b7b49 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,65 @@ 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.layout.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.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.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.SpacerWMax import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition @@ -30,11 +68,14 @@ 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.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 +116,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 +129,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 +148,173 @@ private fun TangemPayCardDetailsHiddenBlock( painter = painterResource(id = imageResId), contentDescription = null, ) - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 16.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = shortCardNumber, - 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) + }, + 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( @@ -313,6 +472,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Frozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -325,6 +485,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -337,6 +498,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Pending, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -349,6 +511,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..673b118af2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -0,0 +1,219 @@ +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.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.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 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) { + item(key = "GooglePay") { + 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)), + ) { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + ) + } + } + } + item(key = "Settings") { + 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)), + ) { + 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), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = item.title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@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/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 1e3a51c9a4..86e06e7ffe 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 @@ -312,6 +312,7 @@ private fun TangemPayDetailsScreenPreview( onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), @@ -391,6 +392,7 @@ private fun TangemPayDetailsTxHistoryScreenPreview( onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ), 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/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/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..8ca7790141 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 @@ -20,6 +20,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 +67,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 +84,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 +112,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 +159,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..c47f2c68ed 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 */ @@ -68,6 +71,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) @@ -111,4 +116,10 @@ dependencies { 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..639595b1f4 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 @@ -96,7 +97,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( TokenDetailsScreen( tokenDetailsUM = tokenDetailsUM, - tokenMarketBlockComponent = tokenMarketBlockComponent, modifier = modifier, ) } else { @@ -148,6 +148,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..d912665f4e 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,7 +18,6 @@ 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 @@ -27,12 +26,12 @@ import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnaly 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( @@ -128,9 +127,6 @@ 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, @@ -150,7 +146,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/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/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index af6e80b86e..81e1b3a3d0 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,8 @@ interface TokenDetailsClickIntents { fun onGenerateExtendedKey() + fun onDynamicAddressesClick() + fun onCopyAddress(): TextReference? fun onAssociateClick() @@ -125,6 +127,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { override fun onGenerateExtendedKey() { /* no op */ } + override fun onDynamicAddressesClick() { /* 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..df70f292b1 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt @@ -0,0 +1,73 @@ +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)) + } +} \ 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 4cd0017182..77922c6e7d 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,17 @@ 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.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.receive.AddressModel @@ -16,21 +20,21 @@ 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 @@ -41,6 +45,7 @@ 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 +58,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 +87,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 +100,17 @@ 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.UpdateTopBarMenuTransformer import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter @@ -109,7 +120,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 @@ -154,7 +164,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 +172,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 +203,7 @@ internal class TokenDetailsModel @Inject constructor( private val stakingJobHolder = JobHolder() private val yieldSupplyBalanceJobHolder = JobHolder() private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + private val redesignBalanceJobHolder = JobHolder() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null private var account: Account.CryptoPortfolio? = null @@ -204,11 +225,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 +246,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 +285,7 @@ internal class TokenDetailsModel @Inject constructor( } init { + initRedesign() updateTopBarMenu() initButtons() updateContent() @@ -299,7 +336,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 +352,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 +384,8 @@ 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 } .launchIn(modelScope) .saveIn(warningsJobHolder) @@ -361,7 +398,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 +420,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, ) @@ -394,11 +431,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, ) @@ -419,14 +456,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(), ) } @@ -463,7 +500,7 @@ internal class TokenDetailsModel @Inject constructor( null } - internalUiState.update { state -> + uiState.update { state -> stateFactory.getStakingInfoState( state = state, stakingEntryInfo = stakingEntryInfo, @@ -485,39 +522,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 { @@ -657,6 +696,15 @@ internal class TokenDetailsModel @Inject constructor( openStaking() } + override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick() + + private fun onDynamicAddressesStateChanged() { + updateTopBarMenu() + modelScope.launch(dispatchers.main) { + cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency) + } + } + override fun onGenerateExtendedKey() { modelScope.launch(dispatchers.main) { val extendedKey = getExtendedPublicKeyForCurrencyUseCase( @@ -845,7 +893,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( @@ -857,29 +910,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) { @@ -973,7 +1026,7 @@ internal class TokenDetailsModel @Inject constructor( } }, ifRight = { - internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() + uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() }, ) } @@ -1015,7 +1068,7 @@ internal class TokenDetailsModel @Inject constructor( showErrorDialog(message) } }, - ifRight = { internalUiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, + ifRight = { uiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, ) } } @@ -1041,7 +1094,7 @@ internal class TokenDetailsModel @Inject constructor( TangemLogger.e("Error: $e") }, ifRight = { - internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() + uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() }, ) } @@ -1075,13 +1128,13 @@ internal class TokenDetailsModel @Inject constructor( } } }, - 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() { @@ -1089,7 +1142,7 @@ internal class TokenDetailsModel @Inject constructor( } override fun onDisposeExpressStatus() { - val bottomSheetState = internalUiState.value.bottomSheetConfig?.content + val bottomSheetState = uiState.value.bottomSheetConfig?.content if (bottomSheetState is ExpressStatusBottomSheetConfig) { modelScope.launch { expressStatusFactory.removeTransactionOnBottomSheetClosed( @@ -1098,7 +1151,7 @@ internal class TokenDetailsModel @Inject constructor( ) } } - internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + uiState.value = stateFactory.getStateWithClosedBottomSheet() } override fun onYieldInfoClick() { @@ -1149,52 +1202,16 @@ internal class TokenDetailsModel @Inject constructor( } 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() }, - ), - ) + dialogFactory.showConfirmHideToken(currency = currency, onConfirm = ::onHideConfirmed) } 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), - ), - ), - ) + dialogFactory.showLinkedTokens(currency = currency) } 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() }, - ), + dialogFactory.showDismissIncompleteTransactionConfirm( + onConfirm = ::onConfirmDismissIncompleteTransactionClick, ) } @@ -1217,7 +1234,7 @@ internal class TokenDetailsModel @Inject constructor( } private fun showErrorDialog(text: TextReference) { - uiMessageSender.send(DialogMessage(message = text)) + dialogFactory.showError(text = text) } private fun checkForActionUpdates() { @@ -1357,30 +1374,112 @@ 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() + } + + 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..7fcf3fadd1 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -0,0 +1,74 @@ +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, + ), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = ""), + stakingBlocksState = null, + 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..91ee9dc832 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,12 +1,15 @@ 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.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList -@Stable +@Immutable internal data class TokenDetailsUM( val topAppBarUM: TokenDetailsTopAppBarUM, val balanceBlockUM: TokenDetailsBalanceBlockUM, @@ -17,8 +20,31 @@ internal data class TokenDetailsUM( 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/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index a57f83eafb..802ec6e0bb 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 @@ -168,6 +168,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/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/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..3ecf879fe4 --- /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.core.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/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..25f0277b18 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,157 @@ 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.foundation.layout.fillMaxWidth +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll + +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountIconUM +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 dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeTint +import kotlinx.collections.immutable.persistentListOf -@Suppress("UnusedParameter") @Composable -internal fun TokenDetailsScreen( - tokenDetailsUM: TokenDetailsUM, - tokenMarketBlockComponent: TokenMarketBlockComponent?, - modifier: Modifier = Modifier, -) { +internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifier = Modifier) { + val topAppBarUM = tokenDetailsUM.topAppBarUM + + val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } + val topBarHeight = 64.dp + val partialCollapsedHeight = topBarHeight + statusBarHeight + val expandedHeight = TokenDetailsBalanceBlockHeight + partialCollapsedHeight + + val behavior = rememberTangemExitUntilCollapsedScrollBehavior( + expandedHeight = expandedHeight, + partialCollapsedHeight = partialCollapsedHeight, + ) + 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, + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(top = topBarHeight), + ) + }, + body = { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(behavior.nestedScrollConnection), + ) { + // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History + } + }, + ) + } + + val rootBackground by LocalRootBackgroundColor.current + val hazeIntensity by animateFloatAsState( + targetValue = (behavior.state.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) + } + } +} + +// 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( + 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 = {}, + ), + ), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = "USDT"), + stakingBlocksState = null, + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = {}, + ), + isBalanceHidden = false, + isMarketPriceAvailable = true, + ), ) } -} \ 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/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..ee3f598efc --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -0,0 +1,239 @@ +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.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.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 + +@Composable +internal fun TokenDetailsBalanceBlock(balanceBlockUM: TokenDetailsBalanceBlockUM, modifier: Modifier = Modifier) { + val rootBackground by LocalRootBackgroundColor.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .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, + 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..8e92b4c203 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 @@ -104,7 +104,6 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider { + 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), + ) + every { tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled } returns true + coEvery { + walletBalanceFetcher.invoke( + WalletBalanceFetcher.Params( + userWalletId = userWalletId, + isPaymentAccountRefactorEnabled = true + ) + ) + } returns mockk() + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + coEvery { + walletBalanceFetcher.invoke( + WalletBalanceFetcher.Params( + userWalletId = userWalletId, + isPaymentAccountRefactorEnabled = true + ) + ) + } + } + + 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, + tangemPayFeatureToggles = tangemPayFeatureToggles, + singleAccountListSupplier = singleAccountListSupplier, + getSelectedWalletSyncUseCase = getSelectedWalletSync, + ) + } +} \ 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..8c0623c3e8 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt @@ -0,0 +1,134 @@ +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.stakingBlocksState).isEqualTo(state.stakingBlocksState) + 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), + ), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = 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..1d7b67394f --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt @@ -0,0 +1,143 @@ +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.stakingBlocksState).isEqualTo(state.stakingBlocksState) + 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, + ), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = 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..27a1733679 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt @@ -0,0 +1,481 @@ +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.stakingBlocksState).isEqualTo(state.stakingBlocksState) + 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, + ), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = 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..7f72295f3a --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt @@ -0,0 +1,204 @@ +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), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = 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..2a288fa164 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt @@ -0,0 +1,203 @@ +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.stakingBlocksState).isEqualTo(state.stakingBlocksState) + 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, + ), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = 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/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..f5c2d80d1e --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt @@ -0,0 +1,214 @@ +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), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index ec188e4f5e..51e2d587f8 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) 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..c91c083d7d 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -125,9 +125,10 @@ 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.account.api) implementation(projects.features.details.api) implementation(projects.features.hotWallet.api) implementation(projects.features.manageTokens.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..3cd335e5c5 --- /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.account.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/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt new file mode 100644 index 0000000000..5ce1fdfab7 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt @@ -0,0 +1,20 @@ +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.feature.wallet.child.managetokens.model.AddAndManageModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddAndManageModule { + + @Binds + @IntoMap + @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..13df43dd76 --- /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.account.PortfolioFetcher +import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.account.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/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index f15fbdfb75..72e1976879 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.account.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 5a2a395cfc..bb44739865 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 @@ -38,7 +38,7 @@ 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.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.domain.wallets.usecase.* import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase @@ -65,7 +65,6 @@ import com.tangem.features.biometry.AskBiometryComponent 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 kotlinx.coroutines.* @@ -118,7 +117,6 @@ 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, @@ -126,7 +124,7 @@ internal class WalletModel @Inject constructor( 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 +157,7 @@ internal class WalletModel @Inject constructor( subscribeTangemPayOnWalletState() subscribeToMainScreenQrScanning() enableNotificationsIfNeeded() - applyPendingTokenSyncs() + applyPendingAssetsDiscovery() clickIntents.initialize(innerWalletRouter, modelScope) @@ -552,7 +550,6 @@ internal class WalletModel @Inject constructor( wallets = action.wallets, clickIntents = clickIntents, walletImageResolver = walletImageResolver, - isMainScreenQrScanningEnabled = walletFeatureToggles.isMainScreenQrScanningEnabled, getWalletIconUseCase = getWalletIconUseCase, isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), @@ -840,9 +837,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/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/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 665908c863..f3642a626e 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 @@ -40,7 +40,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 +97,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 +132,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 +508,12 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( } } - override fun onDismissTokenSyncNotification(userWalletId: UserWalletId) { - acknowledgeTokenSyncCompletionUseCase(userWalletId) + override fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId) { + acknowledgeAssetsDiscoveryCompletionUseCase(userWalletId) } - override fun onTokenSyncManageClick(userWalletId: UserWalletId) { - acknowledgeTokenSyncCompletionUseCase(userWalletId) + override fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId) { + 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..bbdd063032 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 = {}, ), 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/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 f0d03bf32a..4dc9493025 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.features.hotwallet.HotWalletFeatureToggles import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType @@ -65,7 +65,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, ) { @@ -75,11 +75,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, @@ -96,7 +97,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( .distinctUntilChanged(), getUpgradeBannerClosureTimestampUseCase(userWallet.walletId) .distinctUntilChanged(), - tokenSyncProgressFlow, + assetsDiscoveryProgressFlow, ) { array -> array } .map { array -> val accountStatusList = array[0] as AccountStatusList @@ -108,7 +109,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 @@ -153,9 +154,9 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( clickIntents = clickIntents, ) - addTokenSyncCompletedNotification( + addAssetsDiscoveryCompletedNotification( userWallet = userWallet, - tokenSyncProgress = tokenSyncProgress, + assetsDiscoveryProgress = assetsDiscoveryProgress, clickIntents = clickIntents, ) @@ -404,17 +405,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/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/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/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..82b4da90cc 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 @@ -27,7 +27,7 @@ internal sealed interface WalletState : WalletStateHolder { 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, @@ -41,7 +41,7 @@ internal sealed interface WalletState : WalletStateHolder { 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( @@ -63,7 +63,7 @@ internal sealed interface WalletState : WalletStateHolder { 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/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index 4c669daeed..fe74c26c2b 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,7 +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 { @@ -65,15 +64,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/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 35b381f033..ef84cc13c6 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/TangemPayUpdateInfoStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt index 3e1615a6e2..238c8a36f1 100644 --- 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 @@ -80,6 +80,7 @@ internal class TangemPayUpdateInfoStateTransformer( cardFrozenState = cardFrozenState, cardNumberEnd = cardInfo.lastFourDigits, chainId = POLYGON_CHAIN_ID, + displayName = productInstance.displayName, ), ) }, 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/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index 1ce25e26ac..a065f12000 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 @@ -74,6 +74,7 @@ internal class TangemPayMainBlockConverter( cardFrozenState = TangemPayCardFrozenState.Frozen, cardNumberEnd = statusValue.lastFourDigits, chainId = POLYGON_CHAIN_ID, + displayName = statusValue.displayName, ), ) }, @@ -97,6 +98,7 @@ internal class TangemPayMainBlockConverter( cardFrozenState = TangemPayCardFrozenState.Unfrozen, cardNumberEnd = statusValue.lastFourDigits, chainId = POLYGON_CHAIN_ID, + displayName = statusValue.displayName, ), ) }, 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/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/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/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 8f842b82cf..1dcf79012d 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 @@ -726,17 +726,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) { 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/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/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..5b44bc3973 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 @@ -21,6 +21,7 @@ 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/WcSendTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt index 6976bac3a3..f1b56b37bc 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt @@ -2,6 +2,7 @@ package com.tangem.features.walletconnect.transaction.converter import com.tangem.common.ui.account.AccountTitleUM import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.walletconnect.model.WcBitcoinMethod import com.tangem.domain.walletconnect.model.WcEthMethod import com.tangem.domain.walletconnect.model.WcSolanaMethod import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck @@ -16,7 +17,6 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionItemUM import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionUM import com.tangem.features.walletconnect.utils.WcNotificationsFactory -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isHotWallet import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -27,7 +27,6 @@ internal class WcSendTransactionUMConverter @Inject constructor( private val networkInfoUMConverter: WcNetworkInfoUMConverter, private val requestBlockUMConverter: WcTransactionRequestBlockUMConverter, private val notificationsFactory: WcNotificationsFactory, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : 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/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/welcome/impl/build.gradle.kts b/features/welcome/impl/build.gradle.kts index e21ec865c7..11cc45a04f 100644 --- a/features/welcome/impl/build.gradle.kts +++ b/features/welcome/impl/build.gradle.kts @@ -14,7 +14,6 @@ android { dependencies { implementation(projects.features.welcome.api) implementation(projects.features.wallet.api) - implementation(projects.features.hotWallet.api) /** Core */ implementation(projects.core.configToggles) diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index 5c16293dd7..5ecd009e00 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -23,11 +23,11 @@ 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.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.NonBiometricUnlockWalletUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase -import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.features.welcome.impl.ui.state.WelcomeUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -54,7 +54,7 @@ internal class WelcomeModel @Inject constructor( private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, userWalletsFetcherFactory: UserWalletsFetcher.Factory, - private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, private val scanCardProcessor: ScanCardProcessor, private val messageSender: UiMessageSender, ) : Model() { @@ -90,6 +90,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 { @@ -181,7 +183,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) 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/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..78d87c1dbb 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 @@ -11,7 +11,6 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.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 @@ -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, ), ) 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/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 4824105018..345179d5ed 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 @@ -9,7 +9,6 @@ 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 @@ -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..2c6c834887 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 @@ -8,7 +8,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.resourceReference import com.tangem.core.ui.extensions.wrappedList @@ -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, ), ) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f1841ba8e2..91a743b450 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1491" +tangemBlockchainSdk = "develop-1496" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ 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/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")