diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a003607888..01c88fedd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(projects.domain.staking) implementation(projects.domain.walletConnect) implementation(projects.domain.markets) + implementation(projects.domain.manageTokens) implementation(projects.common) implementation(projects.common.routing) @@ -103,6 +104,7 @@ dependencies { implementation(projects.data.staking) implementation(projects.data.walletConnect) implementation(projects.data.markets) + implementation(projects.data.manageTokens) /** Features */ implementation(projects.features.onboarding) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanEmptyTest.kt similarity index 100% rename from app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt rename to app/src/androidTest/kotlin/com/tangem/tests/ScanEmptyTest.kt diff --git a/app/src/main/assets/testnet_tokens.json b/app/src/main/assets/testnet_tokens.json index 78da3c2c81..409a6b6067 100644 --- a/app/src/main/assets/testnet_tokens.json +++ b/app/src/main/assets/testnet_tokens.json @@ -688,6 +688,26 @@ "networkId": "base/test" } ] + }, + { + "id": "blast-ethereum", + "name": "Blast", + "symbol": "ETH", + "networks": [ + { + "networkId": "blast/test" + } + ] + }, + { + "id": "cyberconnect", + "name": "Cyber", + "symbol": "ETH", + "networks": [ + { + "networkId": "cyber/test" + } + ] } ] } diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 967026bf4c..ff4810cfc6 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -32,7 +32,7 @@ import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles +import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles import com.tangem.tap.proxy.AppStateHolder @@ -82,8 +82,6 @@ interface ApplicationEntryPoint { fun getWalletsRepository(): WalletsRepository - fun getSendFeatureToggles(): SendFeatureToggles - fun getOneTimeEventFilter(): OneTimeEventFilter fun getGeneralUserWalletsListManager(): UserWalletsListManager @@ -119,4 +117,6 @@ interface ApplicationEntryPoint { fun getAppRouter(): AppRouter fun getPushNotificationsFeatureToggles(): PushNotificationsFeatureToggles + + fun getTangemAppLogger(): TangemAppLoggerInitializer } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt index 8758b045e3..18f3cff503 100644 --- a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt +++ b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt @@ -35,7 +35,7 @@ internal class LockUserWalletsTimer( val wasApplicationStopped = settingsRepository.wasApplicationStopped() val shouldOpenWelcomeScreenOnResume = settingsRepository.shouldOpenWelcomeScreenOnResume() - Timber.d( + Timber.i( """ Owner resumed |- Was stopped: $wasApplicationStopped @@ -55,7 +55,7 @@ internal class LockUserWalletsTimer( } override fun onStop(owner: LifecycleOwner) { - Timber.d("Owner stopped") + Timber.i("Owner stopped") owner.lifecycleScope.launch { settingsRepository.setWasApplicationStopped(value = true) @@ -63,13 +63,13 @@ internal class LockUserWalletsTimer( } override fun onDestroy(owner: LifecycleOwner) { - Timber.d("Owner destroyed") + Timber.i("Owner destroyed") stop() } fun restart() { if (delayJob == null) return - Timber.d( + Timber.i( """ Timer restart |- Duration millis: ${duration.inWholeMilliseconds} @@ -80,7 +80,7 @@ internal class LockUserWalletsTimer( private fun start(log: Boolean = true) { if (log) { - Timber.d( + Timber.i( """ Timer start |- Duration millis: ${duration.inWholeMilliseconds} @@ -92,7 +92,7 @@ internal class LockUserWalletsTimer( private fun stop(log: Boolean = true) { if (log) { - Timber.d( + Timber.i( """ Timer stop |- Was started: ${delayJob?.isActive ?: false} @@ -114,7 +114,7 @@ internal class LockUserWalletsTimer( val currentTime = System.currentTimeMillis() val wasApplicationStopped = settingsRepository.wasApplicationStopped() - Timber.d( + Timber.i( """ Finished |- App is stopped: $wasApplicationStopped diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index eaca995355..42280203ef 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -6,8 +6,6 @@ import android.content.pm.PackageManager import coil.ImageLoader import coil.ImageLoaderFactory import com.chuckerteam.chucker.api.ChuckerInterceptor -import com.orhanobut.logger.AndroidLogAdapter -import com.orhanobut.logger.Logger import com.tangem.Log import com.tangem.LogFormat import com.tangem.TangemSdkLogger @@ -47,7 +45,6 @@ import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler @@ -55,12 +52,11 @@ import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandle import com.tangem.tap.common.feedback.AdditionalFeedbackInfo import com.tangem.tap.common.feedback.LegacyFeedbackManager import com.tangem.tap.common.images.createCoilImageLoader +import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.common.log.TangemLogCollector -import com.tangem.tap.common.log.TimberFormatStrategy import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.appReducer import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager import com.tangem.tap.domain.tasks.product.DerivationsFinder import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles @@ -71,7 +67,6 @@ import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints import kotlinx.coroutines.runBlocking import org.rekotlin.Store -import timber.log.Timber import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository lateinit var store: Store @@ -140,9 +135,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { private val walletsRepository: WalletsRepository get() = entryPoint.getWalletsRepository() - private val sendFeatureToggles: SendFeatureToggles - get() = entryPoint.getSendFeatureToggles() - private val oneTimeEventFilter: OneTimeEventFilter get() = entryPoint.getOneTimeEventFilter() @@ -196,6 +188,9 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { private val pushNotificationsFeatureToggles: PushNotificationsFeatureToggles get() = entryPoint.getPushNotificationsFeatureToggles() + + private val tangemAppLoggerInitializer: TangemAppLoggerInitializer + get() = entryPoint.getTangemAppLogger() // endregion override fun onCreate() { @@ -207,16 +202,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { fun init() { store = createReduxStore() - if (BuildConfig.LOG_ENABLED) { - Logger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) - Timber.plant( - object : Timber.DebugTree() { - override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - Logger.log(priority, tag, message, t) - } - }, - ) - } + tangemAppLoggerInitializer.initialize() foregroundActivityObserver = ForegroundActivityObserver() activityResultCaller = foregroundActivityObserver @@ -232,8 +218,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { ) } - initWarningMessagesManager() - loadNativeLibraries() if (LogConfig.network.blockchainSdkNetwork) { @@ -271,7 +255,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { appThemeModeRepository = appThemeModeRepository, balanceHidingRepository = balanceHidingRepository, walletsRepository = walletsRepository, - sendFeatureToggles = sendFeatureToggles, generalUserWalletsListManager = generalUserWalletsListManager, wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, @@ -382,8 +365,4 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { ) store.dispatch(GlobalAction.SetFeedbackManager(feedbackManager)) } - - private fun initWarningMessagesManager() { - store.dispatch(GlobalAction.SetWarningManager(WarningMessagesManager())) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/CurrencyConverter.kt b/app/src/main/java/com/tangem/tap/common/CurrencyConverter.kt deleted file mode 100644 index d30c440661..0000000000 --- a/app/src/main/java/com/tangem/tap/common/CurrencyConverter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.tap.common - -import com.tangem.common.extensions.isZero -import com.tangem.tap.common.extensions.scaleToFiat -import java.math.BigDecimal -import java.math.RoundingMode - -/** -[REDACTED_AUTHOR] - */ -class CurrencyConverter( - private val rateValue: BigDecimal, - private val decimals: Int, -) { - private val roundingMode = RoundingMode.HALF_UP - - fun toFiat(crypto: BigDecimal, fiatDecimals: Int = 2): BigDecimal { - return toFiatUnscaled(crypto).setScale(fiatDecimals, roundingMode) - } - - fun toFiatUnscaled(crypto: BigDecimal): BigDecimal { - return rateValue.multiply(crypto).setScale(decimals, roundingMode) - } - - fun toFiatWithPrecision(crypto: BigDecimal): BigDecimal { - return toFiatUnscaled(crypto).scaleToFiat(true) - } - - fun toCrypto(fiat: BigDecimal): BigDecimal { - if (fiat.isZero()) return fiat - - val fiatValue = fiat.setScale(rateValue.scale(), RoundingMode.UP) - val cryptoValue = fiatValue.divide(rateValue, RoundingMode.UP) - return cryptoValue.setScale(decimals, roundingMode) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/GlobalLayoutStateHandler.kt b/app/src/main/java/com/tangem/tap/common/GlobalLayoutStateHandler.kt deleted file mode 100644 index 0a51404cf9..0000000000 --- a/app/src/main/java/com/tangem/tap/common/GlobalLayoutStateHandler.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.tap.common - -import android.view.View -import android.view.ViewTreeObserver -import timber.log.Timber - -/** -[REDACTED_AUTHOR] - */ -class GlobalLayoutStateHandler( - private val view: T, - attachImmediately: Boolean = true, -) : ViewTreeObserver.OnGlobalLayoutListener { - - var onStateChanged: ((T) -> Unit)? = null - - private var isAttached: Boolean = false - - init { - if (attachImmediately) attach() - } - - private fun attach() { - if (isAttached) { - Timber.d("Already attached") - return - } - - isAttached = true - view.viewTreeObserver.addOnGlobalLayoutListener(this) - } - - fun detach() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - isAttached = false - } - - override fun onGlobalLayout() { - onStateChanged?.invoke(view) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/KeyboardObserver.kt b/app/src/main/java/com/tangem/tap/common/KeyboardObserver.kt deleted file mode 100644 index 47f579ca1b..0000000000 --- a/app/src/main/java/com/tangem/tap/common/KeyboardObserver.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tangem.tap.common - -import android.app.Activity -import android.graphics.Rect -import android.util.DisplayMetrics -import android.view.ViewTreeObserver.OnGlobalLayoutListener -import kotlin.math.absoluteValue - -class KeyboardObserver(activity: Activity) { - - private val decorView = activity.window.decorView - private val windowManager = activity.windowManager - private val originalWindowHeight: Int = getWindowHeight() - private val onGlobalLayoutListener: OnGlobalLayoutListener = OnGlobalLayoutListener { onGlobalLayout() } - - private var onKeyboardListener: ((Boolean) -> Unit)? = null - private var lastIsShow = false - private var lastWindowHeight = getWindowHeight() - - fun registerListener(listener: (Boolean) -> Unit) { - decorView.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener) - onKeyboardListener = listener - } - - fun unregisterListener() { - decorView.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalLayoutListener) - onKeyboardListener = null - } - - private fun getWindowHeight() = Rect().apply { decorView.getWindowVisibleDisplayFrame(this) }.bottom - - private fun onGlobalLayout() { - val currentWindowHeight = getWindowHeight() - if (isSoftKeyChanged()) { - lastWindowHeight = currentWindowHeight - return - } - - lastWindowHeight = currentWindowHeight - val isShow = originalWindowHeight != currentWindowHeight - if (lastIsShow == isShow) return - - lastIsShow = isShow - onKeyboardListener?.invoke(isShow) - } - - private fun isSoftKeyChanged() = (lastWindowHeight - getWindowHeight()).absoluteValue == getSoftKeyButtonHeight() - - private fun getSoftKeyButtonHeight(): Int { - val applicationDisplayHeight = DisplayMetrics().apply { - windowManager.defaultDisplay.getMetrics(this) - }.heightPixels - - val realDisplayHeight = DisplayMetrics().apply { - windowManager.defaultDisplay.getRealMetrics(this) - }.heightPixels - - return realDisplayHeight - applicationDisplayHeight - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt index 3b5ae220eb..56c286492d 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt @@ -22,44 +22,6 @@ sealed class Token( class ButtonShareAddress : Receive("Button - Share Address") } - sealed class Send( - event: String, - params: Map = mapOf(), - error: Throwable? = null, - ) : Token("Token / Send", event, params, error) { - - class ScreenOpened : Send(event = "Send Screen Opened") - class ButtonPaste : Send(event = "Button - Paste") - class ButtonQRCode : Send(event = "Button - QR Code") - class ButtonSwapCurrency : Send(event = "Button - Swap Currency") - - class AddressEntered(sourceType: SourceType, validationResult: ValidationResult) : Send( - event = "Address Entered", - params = mapOf( - "Source" to sourceType.name, - "Validation" to validationResult.name, - ), - ) { - enum class SourceType { - QRCode, PasteButton, PastePopup - } - - enum class ValidationResult { - Success, Fail - } - } - - class SelectedCurrency(currency: CurrencyType) : Send( - event = "Selected Currency", - params = mapOf("Type" to currency.value), - ) { - - enum class CurrencyType(val value: String) { - Token(value = "Token"), AppCurrency(value = "App Currency") - } - } - } - sealed class Topup( event: String, params: Map = mapOf(), diff --git a/app/src/main/java/com/tangem/tap/common/entities/Button.kt b/app/src/main/java/com/tangem/tap/common/entities/Button.kt deleted file mode 100644 index b0aca7f994..0000000000 --- a/app/src/main/java/com/tangem/tap/common/entities/Button.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.tap.common.entities - -import com.tangem.tap.features.send.redux.states.ButtonState - -open class Button(val enabled: Boolean) - -open class IndeterminateProgressButton( - val state: ButtonState, -) : Button(state != ButtonState.DISABLED) { - - val progressState: ProgressState - get() = when (state) { - ButtonState.PROGRESS -> ProgressState.Loading - else -> ProgressState.Done - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Bitmap.kt b/app/src/main/java/com/tangem/tap/common/extensions/Bitmap.kt deleted file mode 100644 index 76eea0c892..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/Bitmap.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.common.extensions - -import android.graphics.Bitmap -import java.io.ByteArrayOutputStream - -@Suppress("MagicNumber") -fun Bitmap.toByteArray(): ByteArray { - val stream = ByteArrayOutputStream() - this.compress(Bitmap.CompressFormat.JPEG, 20, stream) - return stream.toByteArray() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt index 5d7a14709f..243a2f8770 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt @@ -12,12 +12,12 @@ fun FragmentManager.showFragmentAllowingStateLoss(name: String, fragmentProvider val currentFragmentName = getBackStackEntryAt(backStackEntryCount - 1).name if (name == currentFragmentName) { - Timber.d("Fragment $name is already at the top of the stack") + Timber.i("Fragment $name is already at the top of the stack") return } } - Timber.d("Showing $name route") + Timber.i("Showing $name route") val isPoppedBack = popBackStackImmediate(name, 0) @@ -30,9 +30,9 @@ fun FragmentManager.showFragmentAllowingStateLoss(name: String, fragmentProvider fragment.showFragment(fragmentManager = this, name) } - Timber.d("Route $name is shown") + Timber.i("Route $name is shown") } else { - Timber.d("Route $name is found in backstack and shown") + Timber.i("Route $name is found in backstack and shown") } } diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt b/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt index a2206cb3bc..773bca1bd5 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt @@ -1,6 +1,5 @@ package com.tangem.tap.common.extensions -import com.tangem.common.extensions.isZero import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -25,20 +24,4 @@ fun BigDecimal.toFormattedString( fun BigDecimal.stripZeroPlainString(): String = this.stripTrailingZeros().toPlainString() -// 0.00 -> 0.00 -// 0.00002345 -> 0.00002 -// 1.00002345 -> 1.00 -// 1.45002345 -> 1.45 -fun BigDecimal.scaleToFiat(applyPrecision: Boolean = false): BigDecimal { - if (this.isZero()) return this - - val scaledFiat = this.setScale(2, RoundingMode.DOWN) - return if (scaledFiat.isZero() && applyPrecision) this.setPrecision(1) else scaledFiat -} - -fun BigDecimal.setPrecision(precision: Int, roundingMode: RoundingMode = RoundingMode.DOWN): BigDecimal { - if (precision == precision() || scale() <= precision) return this - return this.setScale(scale() - precision() + precision, roundingMode) -} - fun BigDecimal.isPositive(): Boolean = this.signum() == 1 \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/WebView.kt b/app/src/main/java/com/tangem/tap/common/extensions/WebView.kt deleted file mode 100644 index 1afe53b3ba..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/WebView.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.tap.common.extensions - -import android.webkit.WebView - -fun WebView.stop() { - stopLoading() - pauseTimers() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt b/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt index e3add3c7cc..e9432205b9 100644 --- a/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt +++ b/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt @@ -31,12 +31,6 @@ class FeedbackDataBuilder( builder.appendDelimiter() builder.appendKeyValue("Blockchain", walletInfo.blockchain.fullName) builder.appendKeyValue("Derivation path", walletInfo.derivationPath) - - // enable later - // if (walletInfo.blockchain == Blockchain.Bitcoin) { - // builder.appendKeyValue("XPUB", infoHolder.extendedPublicKey) - // } - builder.appendKeyValue("Outputs count", walletInfo.outputsCount) if (walletInfo.tokens.isNotEmpty()) { diff --git a/app/src/main/java/com/tangem/tap/common/haptic/DefaultHapticManager.kt b/app/src/main/java/com/tangem/tap/common/haptic/DefaultHapticManager.kt deleted file mode 100644 index 688dea7f67..0000000000 --- a/app/src/main/java/com/tangem/tap/common/haptic/DefaultHapticManager.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.tap.common.haptic - -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import com.tangem.core.ui.haptic.HapticManager - -class DefaultHapticManager(private val vibrator: Vibrator) : HapticManager { - - override fun vibrateShort() { - vibrate(VIBRATION_SHORT_DURATION) - } - - override fun vibrateMeduim() { - vibrate(VIBRATION_MEDIUM_LOW_DURATION) - } - - override fun vibrateLong() { - vibrate(VIBRATION_MEDIUM_DURATION) - } - - private fun vibrate(durationMs: Long) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) - } else { - vibrator.vibrate(durationMs) - } - } - - companion object { - const val VIBRATION_SHORT_DURATION = 50L - const val VIBRATION_MEDIUM_LOW_DURATION = 75L - const val VIBRATION_MEDIUM_DURATION = 100L - const val VIBRATION_LONG_DURATION = 200L - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/haptic/DefaultVibratorHapticManager.kt b/app/src/main/java/com/tangem/tap/common/haptic/DefaultVibratorHapticManager.kt new file mode 100644 index 0000000000..2bcae01131 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/haptic/DefaultVibratorHapticManager.kt @@ -0,0 +1,38 @@ +package com.tangem.tap.common.haptic + +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.annotation.ChecksSdkIntAtLeast +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager + +internal class DefaultVibratorHapticManager( + private val vibrator: Vibrator, +) : VibratorHapticManager { + + private val isHapticEnabled = deviceSupportsVibrationEffects() + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) + private fun deviceSupportsVibrationEffects(): Boolean = when { + !vibrator.hasVibrator() -> false + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + vibrator.areAllEffectsSupported( + TangemHapticEffect.OneTime.DoubleClick.code, + TangemHapticEffect.OneTime.HeavyClick.code, + TangemHapticEffect.OneTime.Tick.code, + TangemHapticEffect.OneTime.Click.code, + ) == Vibrator.VIBRATION_EFFECT_SUPPORT_YES + + Build.VERSION.SDK_INT == Build.VERSION_CODES.Q -> true + + else -> false + } + + override fun performOneTime(effect: TangemHapticEffect.OneTime) { + if (!isHapticEnabled) return + + vibrator.vibrate(VibrationEffect.createPredefined(effect.code)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/leapfrogWidget/TestLeapfrogFragment.kt b/app/src/main/java/com/tangem/tap/common/leapfrogWidget/TestLeapfrogFragment.kt deleted file mode 100644 index 8253e64b37..0000000000 --- a/app/src/main/java/com/tangem/tap/common/leapfrogWidget/TestLeapfrogFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.tangem.tap.common.leapfrogWidget - -import android.os.Bundle -import android.view.View -import android.widget.FrameLayout -import androidx.fragment.app.Fragment -import androidx.transition.TransitionInflater -import by.kirich1409.viewbindingdelegate.viewBinding -import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidget -import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidgetState -import com.tangem.tap.domain.twins.TwinsCardWidget -import com.tangem.wallet.R -import com.tangem.wallet.databinding.TestLeapfrogFragmentBinding - -class TestLeapfrogFragment : Fragment(R.layout.test_leapfrog_fragment) { - - private lateinit var twinsCardWidget: TwinsCardWidget - private val binding: TestLeapfrogFragmentBinding by viewBinding(TestLeapfrogFragmentBinding::bind) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val inflater = TransitionInflater.from(requireContext()) - exitTransition = inflater.inflateTransition(R.transition.fade) - } - - @Suppress("MagicNumber") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val leapfrogContainer: FrameLayout = view.findViewById(R.id.leapfrog_views_container) - val leapfrog = LeapfrogWidget(leapfrogContainer) - twinsCardWidget = TwinsCardWidget(leapfrog) { 200f } - - binding.btnTwinWelcome.setOnClickListener { - twinsCardWidget.toWelcome() - } - binding.btnTwinToLeapfrog.setOnClickListener { - twinsCardWidget.toLeapfrog() - } - binding.btnTwinActivate.setOnClickListener { - twinsCardWidget.toActivate() - } - - binding.btnLpInit.setOnClickListener { - leapfrog.initViews() - } - binding.btnLpUnfold.setOnClickListener { - leapfrog.unfold() - } - binding.btnLpFold.setOnClickListener { - leapfrog.fold() - } - binding.btnLpLeap.setOnClickListener { - leapfrog.leap() - } - binding.btnLpLeapBack.setOnClickListener { - leapfrog.leapBack() - } - } - - override fun onStart() { - super.onStart() - leapfrogWidgetState?.let { twinsCardWidget.leapfrogWidget.applyState(it) } - } - - override fun onStop() { - super.onStop() - leapfrogWidgetState = twinsCardWidget.leapfrogWidget.getState() - } -} - -private var leapfrogWidgetState: LeapfrogWidgetState? = null \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt new file mode 100644 index 0000000000..7a7e2c72b0 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt @@ -0,0 +1,48 @@ +package com.tangem.tap.common.log + +import android.util.Log +import com.orhanobut.logger.AndroidLogAdapter +import com.orhanobut.logger.Logger +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.wallet.BuildConfig +import timber.log.Timber + +/** + * Tangem app logger + * + * @property settingsRepository repository for saving logs + * +[REDACTED_AUTHOR] + */ +class TangemAppLoggerInitializer( + private val settingsRepository: SettingsRepository, +) { + + /** Initialize */ + fun initialize() { + if (IS_LOG_ENABLED) { + Logger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) + } + + Timber.plant(tree = createTimberTree()) + } + + private fun createTimberTree(): Timber.Tree { + return object : Timber.DebugTree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (IS_LOG_ENABLED) { + Logger.log(priority, tag, message, t) + } + + if (PERMITTED_PRIORITY.contains(priority)) { + settingsRepository.saveLogMessage(message) + } + } + } + } + + private companion object { + val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED + val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt index 0da020d6bf..fad6b562f7 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt @@ -4,11 +4,6 @@ import com.tangem.Log import com.tangem.LogFormat import com.tangem.TangemSdkLogger import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock /** * CardSDK logger implementation @@ -16,7 +11,6 @@ import kotlinx.coroutines.sync.withLock * @property levels logging levels * @property messageFormatter message formatter * @property settingsRepository settings repository - * @property dispatchers coroutine dispatchers provider * [REDACTED_AUTHOR] */ @@ -24,19 +18,11 @@ internal class TangemCardSDKLogger( private val levels: List, private val messageFormatter: LogFormat, private val settingsRepository: SettingsRepository, - private val dispatchers: CoroutineDispatcherProvider, ) : TangemSdkLogger { - private val scope = CoroutineScope(dispatchers.main) - private val mutex = Mutex() - override fun log(message: () -> String, level: Log.Level) { if (!levels.contains(level)) return - scope.launch(dispatchers.main) { - mutex.withLock { - settingsRepository.updateAppLogs(message = messageFormatter.format(message, level)) - } - } + settingsRepository.saveLogMessage(message = messageFormatter.format(message, level)) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/recyclerView/SpaceItemDecoration.kt b/app/src/main/java/com/tangem/tap/common/recyclerView/SpaceItemDecoration.kt deleted file mode 100644 index 4ea08ea19e..0000000000 --- a/app/src/main/java/com/tangem/tap/common/recyclerView/SpaceItemDecoration.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.tangem.tap.common.recyclerView - -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.tangem.sdk.extensions.dpToPx - -class SpaceItemDecoration( - private val horizontalSpaceDp: Float, - private val verticalSpaceDp: Float, -) : RecyclerView.ItemDecoration() { - - private lateinit var space: Space - - override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - if (state.itemCount == 0) return - if (!::space.isInitialized) { - space = Space( - view.dpToPx(horizontalSpaceDp).toInt(), - view.dpToPx(verticalSpaceDp).toInt(), - ) - } - - outRect.left = space.horizontal - outRect.right = space.horizontal - - when (state.itemCount) { - 1 -> { - outRect.top = space.vertical - outRect.bottom = space.vertical - } - else -> { - val adapterPosition = parent.getChildAdapterPosition(view) - if (adapterPosition == -1) return - - when (adapterPosition) { - 0 -> { - // first - outRect.top = space.vertical - outRect.bottom = space.vertical / 2 - } - state.itemCount - 1 -> { - // last - outRect.top = space.vertical / 2 - outRect.bottom = space.vertical - } - else -> { - // middle - outRect.top = space.vertical / 2 - outRect.bottom = space.vertical / 2 - } - } - } - } - } - - private data class Space( - val horizontal: Int, - val vertical: Int, - ) - - companion object { - fun all(dp: Float): SpaceItemDecoration = SpaceItemDecoration(dp, dp) - fun vertical(dp: Float): SpaceItemDecoration = SpaceItemDecoration(0f, dp) - fun horizontal(dp: Float): SpaceItemDecoration = SpaceItemDecoration(dp, 0f) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index d047620b5e..cdc2bed475 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 @@ -10,7 +10,6 @@ import com.tangem.tap.features.onboarding.products.otherCards.redux.OnboardingOt import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsReducer import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletReducer import com.tangem.tap.features.saveWallet.redux.SaveWalletReducer -import com.tangem.tap.features.send.redux.reducers.SendScreenReducer import com.tangem.tap.features.tokens.legacy.redux.TokensReducer import com.tangem.tap.features.welcome.redux.WelcomeReducer import com.tangem.tap.proxy.AppStateHolder @@ -28,7 +27,6 @@ fun appReducer(action: Action, state: AppState?, appStateHolder: AppStateHolder) onboardingWalletState = OnboardingWalletReducer.reduce(action, state), onboardingOtherCardsState = OnboardingOtherCardsReducer.reduce(action, state), twinCardsState = TwinCardsReducer.reduce(action, state), - sendState = SendScreenReducer.reduce(action, state.sendState), detailsState = DetailsReducer.reduce(action, state), disclaimerState = DisclaimerReducer.reduce(action, state), tokensState = TokensReducer.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 2a83cbf4df..bdcb0bd1c1 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 @@ -25,8 +25,6 @@ import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWallet import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState import com.tangem.tap.features.saveWallet.redux.SaveWalletMiddleware import com.tangem.tap.features.saveWallet.redux.SaveWalletState -import com.tangem.tap.features.send.redux.middlewares.SendMiddleware -import com.tangem.tap.features.send.redux.states.SendState import com.tangem.tap.features.tokens.legacy.redux.TokensState import com.tangem.tap.features.wallet.redux.middlewares.TradeCryptoMiddleware import com.tangem.tap.features.welcome.redux.WelcomeMiddleware @@ -44,7 +42,6 @@ data class AppState( val onboardingWalletState: OnboardingWalletState = OnboardingWalletState(), val onboardingOtherCardsState: OnboardingOtherCardsState = OnboardingOtherCardsState(), val twinCardsState: TwinCardsState = TwinCardsState(), - val sendState: SendState = SendState(), val detailsState: DetailsState = DetailsState(), val disclaimerState: DisclaimerState = DisclaimerState(), val tokensState: TokensState = TokensState(), @@ -77,7 +74,6 @@ data class AppState( OnboardingWalletMiddleware.handler, OnboardingOtherCardsMiddleware.handler, TwinCardsMiddleware.handler, - SendMiddleware().sendMiddleware, DetailsMiddleware().detailsMiddleware, DisclaimerMiddleware().disclaimerMiddleware, WalletConnectMiddleware().walletConnectMiddleware, diff --git a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt index 30b127fda1..21cca072f7 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt @@ -1,19 +1,15 @@ package com.tangem.tap.common.redux -import com.tangem.domain.common.LogConfig import org.rekotlin.Middleware import timber.log.Timber /** [REDACTED_AUTHOR] */ -val logMiddleware: Middleware = { dispatch, appState -> +val logMiddleware: Middleware = { _, _ -> { nextDispatch -> { action -> - if (LogConfig.storeAction) { - Timber.d("Dispatch action: $action") - // printOnboardingWalletState() - } + Timber.i("Dispatch action: $action") nextDispatch(action) } } 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 ade5d123b2..2b9e953b46 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 @@ -4,7 +4,6 @@ import com.tangem.common.CompletionResult import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.datasource.config.ConfigManager import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.feedback.FeedbackData @@ -13,8 +12,6 @@ import com.tangem.tap.common.redux.DebugErrorAction import com.tangem.tap.common.redux.ErrorAction import com.tangem.tap.common.redux.NotificationAction import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.configurable.warningMessage.WarningMessage -import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager import org.rekotlin.Action sealed class GlobalAction : Action { @@ -72,10 +69,7 @@ sealed class GlobalAction : Action { val walletPublicKey: ByteArray, ) : GlobalAction() - data class HideWarningMessage(val warning: WarningMessage) : GlobalAction() - data class SetConfigManager(val configManager: ConfigManager) : GlobalAction() - data class SetWarningManager(val warningManager: WarningMessagesManager) : GlobalAction() data class SetFeedbackManager(val feedbackManager: LegacyFeedbackManager) : GlobalAction() data class SendEmail(val feedbackData: FeedbackData, val scanResponse: ScanResponse?) : GlobalAction() @@ -93,6 +87,4 @@ sealed class GlobalAction : Action { object FetchUserCountry : GlobalAction() { data class Success(val countryCode: String) : GlobalAction() } - - data class ChangeAppThemeMode(val appThemeMode: AppThemeMode) : 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 index 9c6882deda..43d85d7fc5 100644 --- 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 @@ -12,7 +12,6 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.send.redux.SendAction import com.tangem.tap.network.exchangeServices.BuyExchangeService import com.tangem.tap.network.exchangeServices.CardExchangeRules import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager @@ -56,18 +55,6 @@ private fun handleAction(action: Action, appState: () -> AppState?) { is GlobalAction.RestoreAppCurrency -> { restoreAppCurrency() } - is GlobalAction.HideWarningMessage -> { - store.state.globalState.warningManager?.let { - if (it.hideWarning(action.warning)) { - // if (WarningMessagesManager.isAlreadySignedHashesWarning()) { - // // TODO: No appropriate warningMessage identification. Make it better later - // store.dispatch(WalletAction.Warnings.CheckHashesCount.SaveCardId) - // } - - store.dispatch(SendAction.Warnings.Update) - } - } - } is GlobalAction.SendEmail -> { store.state.globalState.feedbackManager?.sendEmail( feedbackData = action.feedbackData, 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 d85d35f457..6b011082d2 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 @@ -53,7 +53,6 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde is GlobalAction.SetConfigManager -> { globalState.copy(configManager = action.configManager) } - is GlobalAction.SetWarningManager -> globalState.copy(warningManager = action.warningManager) is GlobalAction.UpdateWalletSignedHashes -> { val card = globalState.scanResponse?.card ?: return globalState val wallet = card.wallets @@ -92,9 +91,6 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde userCountryCode = action.countryCode, ) } - is GlobalAction.ChangeAppThemeMode -> globalState.copy( - appThemeMode = action.appThemeMode, - ) 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 50f8f85f47..420951087b 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 @@ -7,7 +7,6 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.feedback.LegacyFeedbackManager import com.tangem.tap.domain.TapWalletManager -import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager import com.tangem.tap.features.onboarding.OnboardingManager import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager import org.rekotlin.StateType @@ -19,7 +18,6 @@ data class GlobalState( val cardVerifiedOnline: Boolean = false, val tapWalletManager: TapWalletManager = TapWalletManager(), val configManager: ConfigManager? = null, - val warningManager: WarningMessagesManager? = null, val feedbackManager: LegacyFeedbackManager? = null, val appCurrency: AppCurrency = AppCurrency.Default, val scanCardFailsCounter: Int = 0, 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 index 80d9f694c8..e4bf1086bd 100644 --- 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 @@ -3,7 +3,7 @@ package com.tangem.tap.common.redux.legacy import com.tangem.blockchain.common.AmountType import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.redux.LegacyAction -import com.tangem.domain.tokens.utils.convertToAmount +import com.tangem.domain.utils.convertToSdkAmount import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.stripZeroPlainString import com.tangem.tap.common.feedback.FeedbackEmail @@ -42,7 +42,7 @@ internal object LegacyMiddleware { is LegacyAction.SendEmailTransactionFailed -> { if (store.inject(DaggerGraphState::feedbackManagerFeatureToggles).isLocalLogsEnabled) { - val amount = action.amount?.convertToAmount(action.cryptoCurrency) + val amount = action.amount?.convertToSdkAmount(action.cryptoCurrency) store.inject(DaggerGraphState::saveBlockchainErrorUseCase).invoke( error = BlockchainErrorInfo( errorMessage = action.errorMessage, @@ -55,7 +55,7 @@ internal object LegacyMiddleware { "" }, amount = amount?.value?.stripZeroPlainString() ?: "unknown", - fee = action.fee?.convertToAmount(action.cryptoCurrency) + fee = action.fee?.convertToSdkAmount(action.cryptoCurrency) ?.value?.stripZeroPlainString() ?: "unknown", ), ) @@ -73,8 +73,8 @@ internal object LegacyMiddleware { )?.let { walletManager -> store.state.globalState.feedbackManager?.infoHolder?.updateOnSendError( walletManager = walletManager, - amountToSend = action.amount?.convertToAmount(action.cryptoCurrency), - feeAmount = action.fee?.convertToAmount(action.cryptoCurrency), + amountToSend = action.amount?.convertToSdkAmount(action.cryptoCurrency), + feeAmount = action.fee?.convertToSdkAmount(action.cryptoCurrency), destinationAddress = action.destinationAddress, ) } diff --git a/app/src/main/java/com/tangem/tap/common/snackBar/MaxAmountSnackbar.kt b/app/src/main/java/com/tangem/tap/common/snackBar/MaxAmountSnackbar.kt deleted file mode 100644 index 939fc17c61..0000000000 --- a/app/src/main/java/com/tangem/tap/common/snackBar/MaxAmountSnackbar.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.tangem.tap.common.snackBar - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams -import com.google.android.material.snackbar.BaseTransientBottomBar -import com.google.android.material.snackbar.ContentViewCallback -import com.google.android.material.snackbar.Snackbar -import com.tangem.sdk.extensions.dpToPx -import com.tangem.wallet.R - -/** -[REDACTED_AUTHOR] - */ -class MaxAmountSnackbar( - parent: ViewGroup, - content: MaxAmountSnackbarView, -) : BaseTransientBottomBar(parent, content, content) { - - companion object { - - fun make(view: View, onClick: () -> Unit): MaxAmountSnackbar { - val parent = view.findSuitableParent() ?: throw IllegalArgumentException( - "No suitable parent found from the given view. Please provide a valid view.", - ) - val inflater = LayoutInflater.from(parent.context) - val customView = inflater.inflate(R.layout.view_snackbar_max_amount, parent, false) as MaxAmountSnackbarView - customView.setOnClickListener { onClick() } - - return MaxAmountSnackbar(parent, customView).apply { - updateBottomMargin() - duration = Snackbar.LENGTH_INDEFINITE - } - } - - private fun MaxAmountSnackbar.updateBottomMargin() { - ViewCompat.setOnApplyWindowInsetsListener(this.view) { _, insets -> - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom - val bottomInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom - - this.view.updateLayoutParams { - bottomMargin = imeInsets - bottomInsets + context.dpToPx(dp = 8f).toInt() - } - - insets - } - } - - private fun View?.findSuitableParent(): ViewGroup? { - var view = this - var fallback: ViewGroup? = null - do { - if (view is CoordinatorLayout) { - return view - } else if (view is FrameLayout) { - if (view.id == android.R.id.content) { - return view - } else { - fallback = view - } - } - - if (view != null) { - val parent = view.parent - view = if (parent is View) parent else null - } - } while (view != null) - return fallback - } - } -} - -class MaxAmountSnackbarView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : ConstraintLayout(context, attrs, defStyleAttr), ContentViewCallback { - - init { - View.inflate(context, R.layout.view_snackbar_max_amount_content, this) - clipToPadding = false - } - - override fun animateContentIn(delay: Int, duration: Int) { - } - - override fun animateContentOut(delay: Int, duration: Int) { - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/text/DecimalDigitsInputFilter.kt b/app/src/main/java/com/tangem/tap/common/text/DecimalDigitsInputFilter.kt deleted file mode 100644 index 2201e794cc..0000000000 --- a/app/src/main/java/com/tangem/tap/common/text/DecimalDigitsInputFilter.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tangem.tap.common.text - -import android.text.InputFilter -import android.text.Spanned -import java.util.regex.Pattern - -/** -[REDACTED_AUTHOR] - */ -class DecimalDigitsInputFilter( - digitsBeforeDecimal: Int, - digitsAfterDecimal: Int, - private val decimalSeparator: String, -) : InputFilter { - private val pattern: Pattern = Pattern.compile( - "(([1-9]{1}[0-9]{0,${digitsBeforeDecimal - 1}})?||[0]{1})" + - "((\\$decimalSeparator[0-9]{0,$digitsAfterDecimal})?)||(\\$decimalSeparator)?", - ) - - override fun filter( - source: CharSequence, - sourceStart: Int, - sourceEnd: Int, - destination: Spanned, - destinationStart: Int, - destinationEnd: Int, - ): CharSequence? { - val destString = destination.toString() - val prefix = destString.substring(0, destinationStart) - val suffix = destString.substring(destinationEnd, destString.length) - val newDestination = prefix + suffix - - val resultPrefix = newDestination.substring(0, destinationStart) - val resultSuffix = newDestination.substring(destinationStart, newDestination.length) - val result = resultPrefix + source.toString() + resultSuffix - - return if (pattern.matcher(result).matches()) { - null - } else { - val replacedWithAppropriateDecimalSeparator = setDecimalSeparator(result, decimalSeparator) - if (pattern.matcher(replacedWithAppropriateDecimalSeparator).matches()) { - decimalSeparator - } else { - "" - } - } - } - - companion object { - fun setDecimalSeparator(value: String, decimalSeparator: String): String { - if (value.contains(decimalSeparator)) return value - - return if (decimalSeparator == ".") { - value.replace(",", decimalSeparator) - } else { - value.replace(".", decimalSeparator) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/text/Truncate.kt b/app/src/main/java/com/tangem/tap/common/text/Truncate.kt deleted file mode 100644 index 9131ec1023..0000000000 --- a/app/src/main/java/com/tangem/tap/common/text/Truncate.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.tangem.tap.common.text - -import android.widget.TextView -import com.tangem.tap.common.extensions.isEven - -/** -[REDACTED_AUTHOR] - */ -enum class TruncateType { - START, MIDDLE, END -} - -interface Truncate { - fun apply(tv: TextView, text: String, with: String): String - - companion object { - fun create(type: TruncateType): Truncate { - return when (type) { - TruncateType.START -> TruncateStart() - TruncateType.MIDDLE -> TruncateMiddle() - TruncateType.END -> TruncateEnd() - } - } - } -} - -abstract class BaseTruncate : Truncate { - protected var hasBeenTruncated = false - - override fun apply(tv: TextView, text: String, with: String): String { - val roughLength = getRoughFitLength(tv, text) - val fittedText = preciseFitting(tv, roughTruncate(text, roughLength), with) - return if (hasBeenTruncated) attachWith(fittedText, with) else fittedText - } - - private fun getRoughFitLength(tv: TextView, text: String): Int { - val existingSpace = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd) - val textWillTakeSpace = tv.paint.measureText(text) - val overSizeRatio: Float = textWillTakeSpace / existingSpace - - val maxLengthOfText = text.length / overSizeRatio - if (text.length <= maxLengthOfText) return text.length - - return maxLengthOfText.toInt() - } - - private fun preciseFitting(tv: TextView, text: String, with: String): String { - if (!hasBeenTruncated) return text - - val spaceForText = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd) - - var fittedText = text - while (tv.paint.measureText(fittedText + with) > spaceForText) { - fittedText = preciseTruncate(fittedText) - } - return fittedText - } - - protected abstract fun roughTruncate(text: String, residualLength: Int): String - protected abstract fun preciseTruncate(text: String): String - protected abstract fun attachWith(text: String, with: String): String -} - -class TruncateStart : BaseTruncate() { - override fun roughTruncate(text: String, residualLength: Int): String { - if (text.length <= residualLength) return text - - hasBeenTruncated = true - return text.substring(residualLength, text.length) - } - - override fun preciseTruncate(text: String): String = text.substring(1, text.length) - - override fun attachWith(text: String, with: String): String = with + text -} - -class TruncateMiddle : BaseTruncate() { - - override fun roughTruncate(text: String, residualLength: Int): String { - if (text.length <= residualLength || residualLength < 0) return text - - hasBeenTruncated = true - val halfOfResidualLength = residualLength / 2 - val leftSide = text.substring(0, halfOfResidualLength) - val rightSide = text.substring(text.length - halfOfResidualLength, text.length) - - return leftSide + rightSide - } - - override fun preciseTruncate(text: String): String { - val middlePosition = text.length / 2 - return if (text.length.isEven()) { - val leftSide = text.substring(0, middlePosition - 1) - val rightSide = text.substring(middlePosition, text.length) - leftSide + rightSide - } else { - val leftSide = text.substring(0, middlePosition) - val rightSide = text.substring(middlePosition + 1, text.length) - leftSide + rightSide - } - } - - override fun attachWith(text: String, with: String): String { - val cuttingPosition = text.length / 2 - val leftSide = text.substring(0, cuttingPosition) - val rightSide = text.substring(cuttingPosition, text.length) - return leftSide + with + rightSide - } -} - -class TruncateEnd : BaseTruncate() { - override fun roughTruncate(text: String, residualLength: Int): String { - if (text.length <= residualLength) return text - - hasBeenTruncated = true - return text.substring(0, residualLength) - } - - override fun preciseTruncate(text: String): String = text.substring(0, text.length - 1) - - override fun attachWith(text: String, with: String): String = text + with -} - -fun TextView.truncateWith(text: String, type: TruncateType, with: String = "..."): String { - val truncate = Truncate.create(type) - return truncate.apply(this, text, with) -} - -fun TextView.truncateMiddleWith(text: String, with: String = "..."): String = - this.truncateWith(text, TruncateType.MIDDLE, with) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt b/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt deleted file mode 100644 index 22817c3f93..0000000000 --- a/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.tangem.tap.common.toggleWidget - -import android.graphics.drawable.Drawable -import android.view.View -import com.google.android.material.button.MaterialButton -import com.tangem.tap.common.entities.ProgressState -import com.tangem.tap.common.extensions.hide -import com.tangem.tap.common.extensions.show - -/** -[REDACTED_AUTHOR] - */ -open class IndeterminateProgressButtonWidget( - private val button: MaterialButton, - private val progress: View, - initialState: ProgressState = ProgressState.Done, -) : ViewStateWidget { - - private var text: CharSequence = button.text - private var icon: Drawable? = button.icon - private var iconGravity: Int? = button.iconGravity - - init { - if (initialState != ProgressState.Done) changeState(initialState) - } - - var isEnabled: Boolean - get() = button.isEnabled - set(value) { - button.isEnabled = value - } - - override val mainView: View = button - - override fun changeState(state: WidgetState) { - val progressState = state as? ProgressState ?: return - - when (progressState) { - ProgressState.Done, ProgressState.Error -> switchToNone() - ProgressState.Loading -> switchToProgress() - else -> {} - } - } - - protected open fun switchToNone() { - button.isClickable = true - button.text = text - button.icon = icon - iconGravity?.let { button.iconGravity = it } - - progress.hide() - } - - protected open fun switchToProgress() { - button.isClickable = false - button.text = "" - button.icon = null - - progress.show() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt b/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt index 903d5542ea..c275de1626 100644 --- a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt @@ -2,33 +2,19 @@ package com.tangem.tap.data import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock /** * BlockchainSDK logger implementation * * @property settingsRepository settings repository - * @property dispatchers coroutine dispatchers provider * [REDACTED_AUTHOR] */ internal class TangemBlockchainSDKLogger( private val settingsRepository: SettingsRepository, - private val dispatchers: CoroutineDispatcherProvider, ) : BlockchainSDKLogger { - private val scope = CoroutineScope(dispatchers.main) - private val mutex = Mutex() - override fun log(level: BlockchainSDKLogger.Level, message: String) { - scope.launch(dispatchers.main) { - mutex.withLock { - settingsRepository.updateAppLogs(message) - } - } + settingsRepository.saveLogMessage(message) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/HapticModule.kt b/app/src/main/java/com/tangem/tap/di/HapticModule.kt index bf8439f09e..dc04a3ce72 100644 --- a/app/src/main/java/com/tangem/tap/di/HapticModule.kt +++ b/app/src/main/java/com/tangem/tap/di/HapticModule.kt @@ -4,9 +4,9 @@ import android.content.Context import android.os.Build import android.os.Vibrator import android.os.VibratorManager -import com.tangem.tap.common.haptic.DefaultHapticManager -import com.tangem.core.ui.haptic.HapticManager -import com.tangem.core.ui.haptic.MockHapticManager +import com.tangem.tap.common.haptic.DefaultVibratorHapticManager +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,7 +20,7 @@ class HapticModule { @Provides @Singleton - fun provideHapticManager(@ApplicationContext context: Context): HapticManager { + fun provideHapticManager(@ApplicationContext context: Context): VibratorHapticManager { val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager vibratorManager.defaultVibrator @@ -29,9 +29,12 @@ class HapticModule { } return if (vibrator.hasVibrator()) { - DefaultHapticManager(vibrator = vibrator) + DefaultVibratorHapticManager(vibrator = vibrator) } else { - MockHapticManager + // mock + object : VibratorHapticManager { + override fun performOneTime(effect: TangemHapticEffect.OneTime) = Unit + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt index 22a522e553..44631d4c05 100644 --- a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt @@ -2,7 +2,7 @@ package com.tangem.tap.di import androidx.compose.material3.SnackbarHostState import com.tangem.core.ui.UiDependencies -import com.tangem.core.ui.haptic.HapticManager +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler import com.tangem.core.ui.theme.AppThemeModeHolder import dagger.Module @@ -17,9 +17,12 @@ internal object UiDependenciesModule { @Provides @Singleton - fun provideUiDependencies(hapticManager: HapticManager, appThemeModeHolder: AppThemeModeHolder): UiDependencies { + fun provideUiDependencies( + vibratorHapticManager: VibratorHapticManager, + appThemeModeHolder: AppThemeModeHolder, + ): UiDependencies { return object : UiDependencies { - override val hapticManager = hapticManager + override val vibratorHapticManager = vibratorHapticManager override val appThemeModeHolder = appThemeModeHolder override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState() override val eventMessageHandler: EventMessageHandler = EventMessageHandler() diff --git a/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt b/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt deleted file mode 100644 index 90906e322d..0000000000 --- a/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.tap.di.data - -import com.tangem.blockchain.common.logging.BlockchainSDKLogger -import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.tap.data.TangemBlockchainSDKLogger -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 BlockchainSDKLoggerModule { - - @Provides - @Singleton - fun provideBlockchainSDKLogger( - settingsRepository: SettingsRepository, - dispatchers: CoroutineDispatcherProvider, - ): BlockchainSDKLogger { - return TangemBlockchainSDKLogger(settingsRepository, dispatchers) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt similarity index 59% rename from app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt rename to app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt index 9b326b64c7..e361ca70a6 100644 --- a/app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt @@ -3,9 +3,11 @@ package com.tangem.tap.di.data import com.tangem.Log import com.tangem.LogFormat import com.tangem.TangemSdkLogger +import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.common.log.TangemCardSDKLogger -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.tap.data.TangemBlockchainSDKLogger import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,14 +16,17 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object CardSDKLoggerModule { +internal object TangemLoggingModule { @Provides @Singleton - fun provideCardSDKLogger( - settingsRepository: SettingsRepository, - dispatchers: CoroutineDispatcherProvider, - ): TangemSdkLogger { + fun provideAppLoggerInitializer(settingsRepository: SettingsRepository): TangemAppLoggerInitializer { + return TangemAppLoggerInitializer(settingsRepository) + } + + @Provides + @Singleton + fun provideCardSDKLogger(settingsRepository: SettingsRepository): TangemSdkLogger { val logLevels = listOf( Log.Level.ApduCommand, Log.Level.Apdu, @@ -40,7 +45,12 @@ internal object CardSDKLoggerModule { levels = logLevels, messageFormatter = LogFormat.StairsFormatter(), settingsRepository = settingsRepository, - dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideBlockchainSDKLogger(settingsRepository: SettingsRepository): BlockchainSDKLogger { + return TangemBlockchainSDKLogger(settingsRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index 0f3fab437c..c74a09521c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -6,6 +6,7 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.demo.DemoConfig import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.tap.domain.card.DefaultDeleteSavedAccessCodesUseCase @@ -69,8 +70,9 @@ internal object CardDomainModule { @Singleton fun provideGetExtendedPublicKeyForCurrencyUseCase( derivationsRepository: DerivationsRepository, + walletManagersFacade: WalletManagersFacade, ): GetExtendedPublicKeyForCurrencyUseCase { - return GetExtendedPublicKeyForCurrencyUseCase(derivationsRepository) + return GetExtendedPublicKeyForCurrencyUseCase(derivationsRepository, walletManagersFacade) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt new file mode 100644 index 0000000000..dd78da6dfd --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt @@ -0,0 +1,20 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.managetokens.GetManagedTokensUseCase +import com.tangem.domain.managetokens.repository.ManageTokensRepository +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 ManageTokensDomainModule { + + @Provides + @Singleton + fun provideGetManageTokensUseCase(manageTokensRepository: ManageTokensRepository): GetManagedTokensUseCase { + return GetManagedTokensUseCase(manageTokensRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 765e3bcb7f..28b30cb9e3 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -1,6 +1,9 @@ package com.tangem.tap.di.domain import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.GetTokenMarketInfoUseCase +import com.tangem.domain.markets.GetTokenPriceChartUseCase +import com.tangem.domain.markets.GetTokenQuotesUseCase import com.tangem.domain.markets.repositories.MarketsTokenRepository import dagger.Module import dagger.Provides @@ -19,4 +22,22 @@ object MarketsDomainModule { ): GetMarketsTokenListFlowUseCase { return GetMarketsTokenListFlowUseCase(marketsTokenRepository = marketsTokenRepository) } + + @Provides + @Singleton + fun provideGetTokenPriceChartUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenPriceChartUseCase { + return GetTokenPriceChartUseCase(marketsTokenRepository = marketsTokenRepository) + } + + @Provides + @Singleton + fun provideGetTokenMarketInfoUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenMarketInfoUseCase { + return GetTokenMarketInfoUseCase(marketsTokenRepository = marketsTokenRepository) + } + + @Provides + @Singleton + fun provideGetTokenQuotesUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenQuotesUseCase { + return GetTokenQuotesUseCase(marketsTokenRepository = marketsTokenRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index c05129c777..bebc27226f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -197,5 +197,11 @@ internal object SettingsDomainModule { ): NeverRequestPermissionUseCase { return NeverRequestPermissionUseCase(repository = permissionRepository) } + + @Provides + @Singleton + fun provideShouldSaveAccessCodesUseCase(settingsRepository: SettingsRepository): ShouldSaveAccessCodesUseCase { + return ShouldSaveAccessCodesUseCase(settingsRepository = settingsRepository) + } // endregion } \ 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 c280208083..a714d71200 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 @@ -156,4 +156,28 @@ internal object StakingDomainModule { stakingErrorResolver = stakingErrorResolver, ) } + + @Provides + @Singleton + fun provideIsApproveNeededUseCase( + stakingRepository: StakingRepository, + stakingErrorResolver: StakingErrorResolver, + ): IsApproveNeededUseCase { + return IsApproveNeededUseCase( + stakingRepository = stakingRepository, + stakingErrorResolver = stakingErrorResolver, + ) + } + + @Provides + @Singleton + fun provideGetConstructedStakingTransactionUseCase( + stakingRepository: StakingRepository, + stakingErrorResolver: StakingErrorResolver, + ): GetConstructedStakingTransactionUseCase { + return GetConstructedStakingTransactionUseCase( + stakingRepository = stakingRepository, + stakingErrorResolver = stakingErrorResolver, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 09792b495e..24c222149b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -413,4 +413,10 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, ) } + + @Provides + @Singleton + fun provideCheckHasLinkedTokensUseCase(currenciesRepository: CurrenciesRepository): CheckHasLinkedTokensUseCase { + return CheckHasLinkedTokensUseCase(currenciesRepository) + } } \ 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 8af69450c8..73c0236567 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 @@ -100,4 +100,18 @@ internal object TransactionDomainModule { ): IsUtxoConsolidationAvailableUseCase { return IsUtxoConsolidationAvailableUseCase(walletManagersFacade) } + + @Provides + @Singleton + fun provideCreateApproveTransactionUseCase( + transactionRepository: TransactionRepository, + ): CreateApprovalTransactionUseCase { + return CreateApprovalTransactionUseCase(transactionRepository) + } + + @Provides + @Singleton + fun provideGetAllowanceUseCase(transactionRepository: TransactionRepository): GetAllowanceUseCase { + return GetAllowanceUseCase(transactionRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt b/app/src/main/java/com/tangem/tap/domain/TapErrors.kt index 70b2540317..1fc37a0267 100644 --- a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt +++ b/app/src/main/java/com/tangem/tap/domain/TapErrors.kt @@ -22,38 +22,14 @@ sealed class TapError( object UnknownError : TapError(R.string.send_error_unknown) open class CustomError(val customMessage: String) : TapError(R.string.common_custom_string, listOf(customMessage)) - data class UnsupportedState( - val stateError: String, - ) : TapError(R.string.common_custom_string, listOf("Unsupported state: $stateError")) object NoInternetConnection : TapError(R.string.wallet_notification_no_internet) - object AmountExceedsBalance : TapError(R.string.send_validation_amount_exceeds_balance) - data class AmountLowerExistentialDeposit( - override val args: List, - ) : TapError(R.string.send_error_minimum_balance_format) - - object FeeExceedsBalance : TapError(R.string.send_validation_invalid_fee) - object TotalExceedsBalance : TapError(R.string.send_validation_invalid_total) - object InvalidAmountValue : TapError(R.string.send_validation_invalid_amount) - object InvalidFeeValue : TapError(R.string.send_error_invalid_fee_value) - data class DustAmount(override val args: List) : TapError(R.string.send_error_dust_amount_format) - object DustChange : TapError(R.string.send_error_dust_change) sealed class WalletManager { class NoAccountError(amountToCreateAccount: String) : CustomError(amountToCreateAccount) class InternalError(message: String) : CustomError(message) object BlockchainIsUnreachableTryLater : TapError(R.string.wallet_balance_blockchain_unreachable_try_later) } - - sealed class WalletConnect { - object UnsupportedDapp : TapError(R.string.wallet_connect_error_unsupported_dapp) - object UnsupportedLink : TapError(R.string.wallet_connect_error_failed_to_connect) - } - - data class ValidateTransactionErrors( - override val errorList: List, - override val builder: (List) -> String, - ) : TapError(-1), MultiMessageError } sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) { diff --git a/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt b/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt index 64e1b31472..5f4b695932 100644 --- a/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt @@ -8,12 +8,10 @@ import com.tangem.common.doOnSuccess import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.operations.derivation.ExtendedPublicKeysMap @@ -46,12 +44,16 @@ internal class DefaultDerivationsRepository( return } + derivePublicKeys(userWalletId = userWalletId, derivations = derivations) + } + + override suspend fun derivePublicKeys(userWalletId: UserWalletId, derivations: Derivations): DerivedKeys { tangemSdkManager.derivePublicKeys(cardId = null, derivations = derivations) .doOnSuccess { response -> updatePublicKeys(userWalletId = userWalletId, keys = response.entries) .doOnSuccess { validateDerivations(scanResponse = it.scanResponse, derivations = derivations) - return + return response.entries } .doOnFailure { throw it } } @@ -60,28 +62,6 @@ internal class DefaultDerivationsRepository( error("This code should never be reached") } - override suspend fun deriveExtendedPublicKey( - userWalletId: UserWalletId, - derivation: DerivationPath, - ): ExtendedPublicKey? { - val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("User wallet not found") - val walletCard = userWallet.scanResponse.card.wallets.firstOrNull { - UserWalletIdBuilder.scanResponse(userWallet.scanResponse).build()?.value - .contentEquals(userWallet.walletId.value) - } ?: return null - - val result = tangemSdkManager.deriveExtendedPublicKey( - cardId = null, - walletPublicKey = walletCard.publicKey, - derivation = derivation, - ) - - return when (result) { - is CompletionResult.Failure -> throw result.error - is CompletionResult.Success -> result.data - } - } - /** * It throws an exception if any of the provided derivations are invalid * Validation for NonHardened moved to application layer, to avoid fails when derive multiple paths diff --git a/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessagesManager.kt b/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessagesManager.kt deleted file mode 100644 index caa4125ff7..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessagesManager.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.tap.domain.configurable.warningMessage - -import com.tangem.blockchain.common.Blockchain -import java.util.concurrent.CopyOnWriteArrayList - -/** -[REDACTED_AUTHOR] - */ -// TODO: Delete with SendFeatureToggles -@Deprecated(message = "Used only in old send screen") -class WarningMessagesManager { - - private val warningsList = CopyOnWriteArrayList() - - fun getWarnings(location: WarningMessage.Location, blockchains: List): List { - return warningsList.filter { message -> - val messageBlockchains = message.blockchainList - val isCorrespondingMessageBlockchains = messageBlockchains == null || - messageBlockchains.any(blockchains::contains) - val isCorrespondingMessageLocation = message.location.contains(location) - - !message.isHidden && isCorrespondingMessageLocation && isCorrespondingMessageBlockchains - } - } - - fun hideWarning(warning: WarningMessage): Boolean { - val foundWarning = findWarning(warning) ?: return false - val isCorrectType = foundWarning.type == WarningMessage.Type.Temporary || - foundWarning.type == WarningMessage.Type.AppRating - - return if (!foundWarning.isHidden && isCorrectType) { - foundWarning.isHidden = true - true - } else { - false - } - } - - private fun findWarning(warning: WarningMessage): WarningMessage? { - return warningsList.firstOrNull { it == warning } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt index 2901b7120b..4cad89ba5d 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt @@ -165,10 +165,10 @@ internal class GeneralUserWalletsListManager( } if (possibleManager == implementation.value) { - Timber.e("Switch to same manager ${possibleManager::class.simpleName}") + Timber.e("Switch to the same manager ${possibleManager::class.simpleName}") } - Timber.d("Switch to ${possibleManager::class.simpleName}") + Timber.i("Switch to ${possibleManager::class.simpleName}") val previousManager = implementation.value implementation.value = copySelectedUserWallet( diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt index cea126bf75..8f80642201 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt @@ -62,7 +62,7 @@ internal class DefaultLegacyWalletConnectRepository( application = application, metaData = appMetaData, ) { error -> - Timber.d("Error while initializing client: $error") + Timber.e("Error while initializing client: $error") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -73,7 +73,7 @@ internal class DefaultLegacyWalletConnectRepository( } Web3Wallet.initialize(Wallet.Params.Init(core = CoreClient)) { error -> - Timber.d("Error while initializing Web3Wallet: $error") + Timber.e("Error while initializing Web3Wallet: $error") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -94,11 +94,11 @@ internal class DefaultLegacyWalletConnectRepository( verifyContext: Wallet.Model.VerifyContext, ) { // Triggered when wallet receives the session proposal sent by a Dapp - Timber.d("sessionProposal: $sessionProposal") + Timber.i("sessionProposal: $sessionProposal") this@DefaultLegacyWalletConnectRepository.sessionProposal = sessionProposal if (sessionProposal.name in unsupportedDApps) { - Timber.w("Unsupported DApp") + Timber.i("Unsupported DApp") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -115,7 +115,7 @@ internal class DefaultLegacyWalletConnectRepository( ) if (missingNetworks.isNotEmpty()) { - Timber.w("Not added blockchains: $missingNetworks") + Timber.i("Not added blockchains: $missingNetworks") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -150,12 +150,12 @@ internal class DefaultLegacyWalletConnectRepository( verifyContext: Wallet.Model.VerifyContext, ) { // Triggered when a Dapp sends SessionRequest to sign a transaction or a message - Timber.d("sessionRequest: $sessionRequest") + Timber.i("sessionRequest: $sessionRequest") val request = wcRequestDeserializer.deserialize( method = sessionRequest.request.method, params = sessionRequest.request.params, ) - Timber.d("sessionRequestParsed: $request") + Timber.i("sessionRequestParsed: $request") when (request) { is WcRequest.AddChain -> { @@ -193,7 +193,7 @@ internal class DefaultLegacyWalletConnectRepository( verifyContext: Wallet.Model.VerifyContext, ) { // Triggered when Dapp / Requester makes an authorization request - Timber.d("onAuthRequest: $authRequest") + Timber.i("onAuthRequest: $authRequest") } override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { @@ -204,12 +204,12 @@ internal class DefaultLegacyWalletConnectRepository( updateSessionsInternal().join() } } - Timber.d("onSessionDelete: $sessionDelete") + Timber.i("onSessionDelete: $sessionDelete") } override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { // Triggered when wallet receives the session settlement response from Dapp - Timber.d("onSessionSettleResponse: $settleSessionResponse") + Timber.i("onSessionSettleResponse: $settleSessionResponse") if (settleSessionResponse is Wallet.Model.SettledSessionResponse.Result) { scope.launch { _events.emit( @@ -225,19 +225,19 @@ internal class DefaultLegacyWalletConnectRepository( override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) { // Triggered when wallet receives the session update response from Dapp - Timber.d("onSessionUpdateResponse: $sessionUpdateResponse") + Timber.i("onSessionUpdateResponse: $sessionUpdateResponse") updateSessionsInternal() } override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) { // Triggered whenever the connection state is changed - Timber.d("onConnectionStateChange: $state") + Timber.i("onConnectionStateChange: $state") if (state.isAvailable) updateSessionsInternal() } override fun onError(error: Wallet.Model.Error) { // Triggered whenever there is an issue inside the SDK - Timber.d("onError: $error") + Timber.i("onError: $error") } } } @@ -299,12 +299,12 @@ internal class DefaultLegacyWalletConnectRepository( }, ) - Timber.d("Session approval is prepared for sending: $sessionApproval") + Timber.i("Session approval is prepared for sending: $sessionApproval") Web3Wallet.approveSession( params = sessionApproval, onSuccess = { - Timber.d("Approved successfully: $it") + Timber.i("Approved successfully: $it") analyticsHandler.send( WalletConnect.NewSessionEstablished( dAppName = sessionProposal.name, @@ -313,7 +313,7 @@ internal class DefaultLegacyWalletConnectRepository( ) }, onError = { - Timber.d("Error while approving: $it") + Timber.e("Error while approving: $it") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -370,10 +370,10 @@ internal class DefaultLegacyWalletConnectRepository( ), ), onSuccess = { response -> - Timber.d("Session request responded successfully: $response") + Timber.i("Session request responded successfully: $response") }, onError = { error -> - Timber.d(error.throwable, "Error while responging session request") + Timber.e(error.throwable, "Error while responging session request") WalletConnect.RequestHandledParams( dAppName = session?.name ?: "", @@ -427,10 +427,10 @@ internal class DefaultLegacyWalletConnectRepository( reason = "", ), onSuccess = { - Timber.d("Rejected successfully: $it") + Timber.i("Rejected successfully: $it") }, onError = { - Timber.d("Error while rejecting: $it") + Timber.e("Error while rejecting: $it") }, ) } @@ -447,10 +447,10 @@ internal class DefaultLegacyWalletConnectRepository( ), ) updateSessionsInternal() - Timber.d("Disconnected successfully: $it") + Timber.i("Disconnected successfully: $it") }, onError = { - Timber.d("Error while disconnecting: $it") + Timber.e("Error while disconnecting: $it") }, ) } @@ -483,7 +483,7 @@ internal class DefaultLegacyWalletConnectRepository( url = it.metaData?.url, ) } - Timber.d("Available sessions: $availableSessions") + Timber.i("Available sessions: $availableSessions") currentSessions = availableSessions _activeSessions.emit(availableSessions) } diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt index 1f5dce9663..122a9ed76f 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt @@ -35,7 +35,7 @@ class WalletConnectInteractor( val blockchainHelper: WcBlockchainHelper, ) { - var isWalletConnectReadyForDeepLinks = false + private var isWalletConnectReadyForDeepLinks = false private val getSelectedWalletUseCase by lazy(LazyThreadSafetyMode.NONE) { GetSelectedWalletUseCase(userWalletsListManager) @@ -82,7 +82,7 @@ class WalletConnectInteractor( private suspend fun initWithWallet(userWallet: UserWallet) { if (userWallet.isMultiCurrency) { - Timber.d("WalletConnect: initialize and setup networks for ${userWallet.walletId}") + Timber.i("WalletConnect: initialize and setup networks for ${userWallet.walletId}") startListeningWc(userWallet.walletId.stringValue, getCardId(userWallet)) subscribeOnCurrenciesUpdates(userWallet) } @@ -143,9 +143,9 @@ class WalletConnectInteractor( private suspend fun subscribeToEvents() { events .onEach { wcEvent -> + Timber.i("WalletConnect: event: $wcEvent") when (wcEvent) { is WalletConnectEvents.SessionProposal -> { - Timber.d("WC session proposal event received") val unsupportedNetworks = wcEvent.requiredChainIds .filter { blockchainHelper.chainIdToNetworkIdOrNull(it) == null } if (unsupportedNetworks.isNotEmpty()) { @@ -230,6 +230,7 @@ class WalletConnectInteractor( } fun approveSessionProposal(accounts: List) { + Timber.i("Approve session proposal: $accounts") val userNamespaces: Map> = accounts .groupBy { account -> blockchainHelper.getNamespaceFromFullChainIdOrNull(account.chainId) @@ -241,14 +242,17 @@ class WalletConnectInteractor( } fun rejectSessionProposal() { + Timber.i("Reject session proposal") walletConnectRepository.reject() } fun disconnectSession(topic: String) { + Timber.i("Disconnect session: $topic") walletConnectRepository.disconnect(topic) } fun cancelRequest(topic: String, id: Long) { + Timber.i("Cancel request: $topic, $id") walletConnectRepository.cancelRequest(topic, id) } diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt index ad210d7625..bd9cc89301 100644 --- a/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt @@ -4,7 +4,7 @@ import arrow.core.getOrElse import arrow.core.raise.result import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toNetworkId -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.card.DerivePublicKeysUseCase import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.features.addCustomToken.CustomCurrency 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 index 14a1147b89..40f3d03a11 100644 --- 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 @@ -134,8 +134,6 @@ class DetailsMiddleware { scope.launch { repository.changeAppThemeMode(appThemeMode) - - store.dispatchWithMain(GlobalAction.ChangeAppThemeMode(appThemeMode)) } } diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt index f3fae68135..508c8a2f4e 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeFragment.kt @@ -6,10 +6,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.Analytics - import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.components.SystemBarsIconsDisposable import com.tangem.core.ui.screen.ComposeFragment @@ -18,6 +18,7 @@ import com.tangem.tap.common.analytics.events.IntroductionProcess import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.home.compose.StoriesScreen +import com.tangem.tap.features.home.featuretoggles.HomeFeatureToggles import com.tangem.tap.features.home.redux.HomeAction import com.tangem.tap.features.home.redux.HomeState import com.tangem.tap.store @@ -26,17 +27,21 @@ import org.rekotlin.StoreSubscriber import javax.inject.Inject @AndroidEntryPoint -class HomeFragment : ComposeFragment(), StoreSubscriber { +internal class HomeFragment : ComposeFragment(), StoreSubscriber { @Inject override lateinit var uiDependencies: UiDependencies + @Inject + lateinit var homeFeatureToggles: HomeFeatureToggles + private var homeState: MutableState = mutableStateOf(store.state.homeState) + private val viewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) store.dispatch(HomeAction.OnCreate) - store.dispatch(HomeAction.Init) } @Composable @@ -73,17 +78,29 @@ class HomeFragment : ComposeFragment(), StoreSubscriber { StoriesScreen( homeState = homeState, onScanButtonClick = { - Analytics.send(IntroductionProcess.ButtonScanCard()) - store.dispatch(action = HomeAction.ReadCard(scope = requireActivity().lifecycleScope)) + if (homeFeatureToggles.isCallbacksRefactoringEnabled) { + viewModel.onScanClick() + } else { + Analytics.send(IntroductionProcess.ButtonScanCard()) + store.dispatch(action = HomeAction.ReadCard(scope = requireActivity().lifecycleScope)) + } }, onShopButtonClick = { - Analytics.send(IntroductionProcess.ButtonBuyCards()) - store.dispatch(HomeAction.GoToShop(store.state.globalState.userCountryCode)) + if (homeFeatureToggles.isCallbacksRefactoringEnabled) { + viewModel.onShopClick() + } else { + Analytics.send(IntroductionProcess.ButtonBuyCards()) + store.dispatch(HomeAction.GoToShop) + } }, onSearchTokensClick = { - Analytics.send(IntroductionProcess.ButtonTokensList()) - store.dispatchNavigationAction { push(AppRoute.ManageTokens) } - store.dispatch(TokensAction.SetArgs.ReadAccess) + if (homeFeatureToggles.isCallbacksRefactoringEnabled) { + viewModel.onSearchClick() + } else { + Analytics.send(IntroductionProcess.ButtonTokensList()) + store.dispatchNavigationAction { push(AppRoute.ManageTokens(readOnlyContent = true)) } + store.dispatch(TokensAction.SetArgs.ReadAccess) + } }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeViewModel.kt b/app/src/main/java/com/tangem/tap/features/home/HomeViewModel.kt new file mode 100644 index 0000000000..c545f2f83b --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/home/HomeViewModel.kt @@ -0,0 +1,131 @@ +package com.tangem.tap.features.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.Analytics +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.SetAccessCodeRequestPolicyUseCase +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.settings.ShouldSaveAccessCodesUseCase +import com.tangem.domain.tokens.TokensAction +import com.tangem.domain.wallets.builder.UserWalletBuilder +import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter +import com.tangem.tap.common.analytics.events.IntroductionProcess +import com.tangem.tap.common.analytics.events.Shop +import com.tangem.tap.common.extensions.dispatchNavigationAction +import com.tangem.tap.common.extensions.onUserWalletSelected +import com.tangem.tap.features.home.redux.HIDE_PROGRESS_DELAY +import com.tangem.tap.features.home.redux.HomeAction +import com.tangem.tap.features.home.redux.HomeMiddleware.NEW_BUY_WALLET_URL +import com.tangem.tap.store +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@Suppress("LongParameterList") +@HiltViewModel +internal class HomeViewModel @Inject constructor( + private val shouldSaveAccessCodesUseCase: ShouldSaveAccessCodesUseCase, + private val setAccessCodeRequestPolicyUseCase: SetAccessCodeRequestPolicyUseCase, + private val scanCardProcessor: ScanCardProcessor, + private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val saveWalletUseCase: SaveWalletUseCase, + private val urlOpener: UrlOpener, + private val analyticsEventHandler: AnalyticsEventHandler, +) : ViewModel() { + + fun onScanClick() { + analyticsEventHandler.send(IntroductionProcess.ButtonScanCard()) + scanCard() + } + + fun onShopClick() { + analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards()) + analyticsEventHandler.send(Shop.ScreenOpened()) + + urlOpener.openUrl(NEW_BUY_WALLET_URL) + } + + fun onSearchClick() { + analyticsEventHandler.send(IntroductionProcess.ButtonTokensList()) + + store.dispatch(TokensAction.SetArgs.ReadAccess) + store.dispatchNavigationAction { push(AppRoute.ManageTokens(readOnlyContent = true)) } + } + + private fun scanCard() { + viewModelScope.launch { + setAccessCodeRequestPolicyUseCase(isBiometricsRequestPolicy = shouldSaveAccessCodesUseCase()) + + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + onProgressStateChange = { showProgress -> + if (showProgress) { + store.dispatch(HomeAction.ScanInProgress(scanInProgress = true)) + } else { + delay(HIDE_PROGRESS_DELAY) + store.dispatch(HomeAction.ScanInProgress(scanInProgress = false)) + } + }, + onFailure = { + Timber.e(it, "Unable to scan card") + delay(HIDE_PROGRESS_DELAY) + store.dispatch(HomeAction.ScanInProgress(scanInProgress = false)) + }, + onSuccess = ::proceedWithScanResponse, + ) + } + } + + private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) { + val userWallet = UserWalletBuilder( + scanResponse = scanResponse, + generateWalletNameUseCase = generateWalletNameUseCase, + ).build() + + if (userWallet == null) { + Timber.e("User wallet not created") + return + } + + saveWalletUseCase(userWallet).fold( + ifLeft = { Timber.e(it.toString(), "Unable to save user wallet") }, + ifRight = { + sendSignedInCardAnalyticsEvent(scanResponse) + coroutineScope { store.onUserWalletSelected(userWallet = userWallet) } + }, + ) + + store.dispatch(HomeAction.ScanInProgress(scanInProgress = false)) + delay(HIDE_PROGRESS_DELAY) + + store.dispatchNavigationAction { push(AppRoute.Wallet) } + } + + private fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse) { + val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver) + + if (currency != null) { + Analytics.send( + event = Basic.SignedIn( + currency = currency, + batch = scanResponse.card.batchId, + signInType = Basic.SignedIn.SignInType.Card, + walletsCount = "1", + hasBackup = scanResponse.card.backupStatus?.isActive, + ), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt index 1b0c69fb9c..9f4e9fbaa9 100644 --- a/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/home/compose/StoriesScreen.kt @@ -33,7 +33,7 @@ import com.tangem.wallet.R import kotlin.math.max @Composable -fun StoriesScreen( +internal fun StoriesScreen( homeState: MutableState, onScanButtonClick: () -> Unit, onShopButtonClick: () -> Unit, diff --git a/app/src/main/java/com/tangem/tap/features/home/featuretoggles/HomeFeatureToggles.kt b/app/src/main/java/com/tangem/tap/features/home/featuretoggles/HomeFeatureToggles.kt new file mode 100644 index 0000000000..8d23463a53 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/home/featuretoggles/HomeFeatureToggles.kt @@ -0,0 +1,12 @@ +package com.tangem.tap.features.home.featuretoggles + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager +import javax.inject.Inject + +internal class HomeFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) { + + val isCallbacksRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "HOME_SCREEN_CALLBACKS_REFACTORING_ENABLED") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt index 2266971dad..f0171194c2 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeAction.kt @@ -5,22 +5,17 @@ import org.rekotlin.Action sealed class HomeAction : Action { - object OnCreate : HomeAction() - object Init : HomeAction() - - data class InsertStory(val position: Int, val story: Stories) : HomeAction() + data object OnCreate : HomeAction() /** * Action for scanning card * - * @property scope lifecycle scope. It will be canceled when lifecycle-aware component is destroyed + * @property scope lifecycle scope. It will be canceled when lifecycle-aware component is destroyed */ - data class ReadCard( - val scope: CoroutineScope, - ) : HomeAction() + data class ReadCard(val scope: CoroutineScope) : HomeAction() data class ScanInProgress(val scanInProgress: Boolean) : HomeAction() - data class GoToShop(val userCountryCode: String?) : HomeAction() + data object GoToShop : HomeAction() data class UpdateCountryCode(val userCountryCode: String) : HomeAction() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt index 38c890dc29..9da71e64e8 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt @@ -29,7 +29,7 @@ import org.rekotlin.Action import org.rekotlin.Middleware import timber.log.Timber -private const val HIDE_PROGRESS_DELAY = 400L +internal const val HIDE_PROGRESS_DELAY = 400L object HomeMiddleware { val handler = homeMiddleware @@ -51,8 +51,7 @@ private fun handleHomeAction(action: Action) { is HomeAction.OnCreate -> { Analytics.eraseContext() Analytics.send(IntroductionProcess.ScreenOpened()) - } - is HomeAction.Init -> { + store.dispatch(GlobalAction.RestoreAppCurrency) store.dispatch(GlobalAction.ExchangeManager.Init) store.dispatch(GlobalAction.FetchUserCountry) diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt index bc71e1fef2..f9d977fdec 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeReducer.kt @@ -12,13 +12,6 @@ private fun internalReduce(action: Action, appState: AppState): HomeState { var state = appState.homeState when (action) { - is HomeAction.InsertStory -> { - state = state.copy( - stories = state.stories.toMutableList().apply { - add(action.position, action.story) - }, - ) - } is HomeAction.ScanInProgress -> { state = state.copy(scanInProgress = action.scanInProgress) } diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index fd2b740b37..375a326b2b 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -7,6 +7,7 @@ import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.Basic +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.domain.appcurrency.FetchAppCurrenciesUseCase import com.tangem.domain.balancehiding.BalanceHidingSettings import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -18,7 +19,6 @@ import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase import com.tangem.domain.staking.FetchStakingTokensUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.tap.common.extensions.setContext import com.tangem.tap.features.main.model.MainScreenState @@ -37,16 +37,16 @@ internal class MainViewModel @Inject constructor( private val listenToFlipsUseCase: ListenToFlipsUseCase, private val router: AppRouter, private val fetchAppCurrenciesUseCase: FetchAppCurrenciesUseCase, - private val deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase, + deleteDeprecatedLogsUseCase: DeleteDeprecatedLogsUseCase, private val incrementAppLaunchCounterUseCase: IncrementAppLaunchCounterUseCase, private val blockchainSDKFactory: BlockchainSDKFactory, private val userWalletsListManager: UserWalletsListManager, private val walletManagersFacade: WalletManagersFacade, - private val sendFeatureToggles: SendFeatureToggles, private val feedbackManagerFeatureToggles: FeedbackManagerFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, - private val stakingFeatureToggles: StakingFeatureToggles, + stakingFeatureToggles: StakingFeatureToggles, private val fetchStakingTokensUseCase: FetchStakingTokensUseCase, + private val apiConfigsManager: ApiConfigsManager, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel(), MainIntents { @@ -68,7 +68,6 @@ internal class MainViewModel @Inject constructor( viewModelScope.launch(dispatchers.main) { incrementAppLaunchCounterUseCase() } updateAppCurrencies() - updateSendFeatureToggle() observeFlips() displayBalancesHidingStatusToast() displayHiddenBalancesModalNotification() @@ -77,14 +76,14 @@ internal class MainViewModel @Inject constructor( fetchStakingTokens() } - viewModelScope.launch(dispatchers.main) { - deleteDeprecatedLogsUseCase() - } + deleteDeprecatedLogsUseCase() } /** Loading the resources needed to run the application */ private fun loadApplicationResources() { viewModelScope.launch(dispatchers.main) { + apiConfigsManager.initialize() + blockchainSDKFactory.init() prepareSelectedWalletFeedback() @@ -130,12 +129,6 @@ internal class MainViewModel @Inject constructor( } } - private fun updateSendFeatureToggle() { - viewModelScope.launch(dispatchers.main) { - sendFeatureToggles.fetchNewSendEnabled() - } - } - private fun observeFlips() { listenToFlipsUseCase().launchIn(viewModelScope) } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt index ef320383b2..52af2f7d66 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt @@ -5,7 +5,7 @@ import com.tangem.common.extensions.guard import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.Analytics -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.common.extensions.makePrimaryWalletManager import com.tangem.domain.common.extensions.withMainContext import com.tangem.domain.common.util.derivationStyleProvider diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt index 19d77c509e..047a5caaa8 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt @@ -7,7 +7,7 @@ import com.tangem.common.routing.AppRoute import com.tangem.common.routing.utils.popTo import com.tangem.core.analytics.Analytics -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.common.extensions.makePrimaryWalletManager import com.tangem.domain.common.extensions.withMainContext import com.tangem.domain.common.util.derivationStyleProvider diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/SendScreenAction.kt b/app/src/main/java/com/tangem/tap/features/send/redux/SendScreenAction.kt deleted file mode 100644 index 35ee864987..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/SendScreenAction.kt +++ /dev/null @@ -1,215 +0,0 @@ -package com.tangem.tap.features.send.redux - -import com.tangem.Message -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.FeePaidCurrency -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.core.TangemSdkError -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.redux.StateDialog -import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.tap.common.analytics.events.Token.Send.AddressEntered -import com.tangem.tap.common.redux.ToastNotificationAction -import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.configurable.warningMessage.WarningMessage -import com.tangem.tap.features.send.redux.states.ButtonState -import com.tangem.tap.features.send.redux.states.FeeType -import com.tangem.tap.features.send.redux.states.MainCurrencyType -import com.tangem.wallet.R -import org.rekotlin.Action -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -interface SendScreenAction : Action -interface SendScreenActionUi : SendScreenAction - -object ReleaseSendState : Action - -data class PrepareSendScreen( - val walletManager: WalletManager, - val feePaidCurrency: FeePaidCurrency, - val currency: CryptoCurrency, - val coinAmount: Amount?, - val coinRate: BigDecimal?, - val tokenAmount: Amount? = null, - val tokenRate: BigDecimal? = null, - val feeCurrencyRate: BigDecimal? = null, - val feeCurrencyDecimals: Int = 0, -) : SendScreenAction - -// Address -sealed class AddressActionUi : SendScreenActionUi { - data class HandleUserInput(val data: String) : AddressActionUi() - data class PasteAddress(val data: String, val sourceType: AddressEntered.SourceType) : AddressActionUi() - data class CheckClipboard(val data: String?) : AddressActionUi() - data class CheckAddress(val sourceType: AddressEntered.SourceType?) : AddressActionUi() - data class SetTruncateHandler(val handler: (String) -> String) : AddressActionUi() - data class TruncateOrRestore(val truncate: Boolean) : AddressActionUi() -} - -sealed class TransactionExtrasAction : SendScreenActionUi { - data class Prepare( - val blockchain: Blockchain, - val walletAddress: String, - val xrpTag: String?, - ) : TransactionExtrasAction() - - object Release : TransactionExtrasAction() - - @Deprecated("Only in legacy send screen") - sealed class XlmMemo : TransactionExtrasAction() { - // data class ChangeSelectedMemo(val memoType: XlmMemoType) : XlmMemo() - data class HandleUserInput(val data: String) : XlmMemo() - } - - @Deprecated("Only in legacy send screen") - sealed class BinanceMemo : TransactionExtrasAction() { - data class HandleUserInput(val data: String) : BinanceMemo() - } - - @Deprecated("Only in legacy send screen") - sealed class XrpDestinationTag : TransactionExtrasAction() { - data class HandleUserInput(val data: String) : XrpDestinationTag() - } - - @Deprecated("Only in legacy send screen") - sealed class TonMemo : TransactionExtrasAction() { - data class HandleUserInput(val data: String) : TonMemo() - } - - @Deprecated("Only in legacy send screen") - sealed class CosmosMemo : TransactionExtrasAction() { - data class HandleUserInput(val data: String) : CosmosMemo() - } - - @Deprecated("Only in legacy send screen") - sealed class HederaMemo : TransactionExtrasAction() { - data class HandleUserInput(val data: String) : HederaMemo() - } - - @Deprecated("Only in legacy send screen") - sealed class AlgorandMemo : TransactionExtrasAction() { - data class HandleUserInput(val data: String) : AlgorandMemo() - } -} - -sealed class AddressVerifyAction : SendScreenAction { - enum class Error { - ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN, - ADDRESS_SAME_AS_WALLET, - } - - data class ChangePasteBtnEnableState(val isEnabled: Boolean) : AddressVerifyAction() - - sealed class AddressVerification : AddressVerifyAction() { - data class SetAddressError(val error: Error?) : AddressVerification() - data class SetWalletAddress(val address: String, val isUserInput: Boolean) : AddressVerification() - } -} - -// Amount to send -sealed class AmountActionUi : SendScreenActionUi { - data class HandleUserInput(val data: String) : AmountActionUi() - object CheckAmountToSend : AmountActionUi() - object SetMaxAmount : AmountActionUi() - data class SetMainCurrency(val mainCurrency: MainCurrencyType) : AmountActionUi() - object ToggleMainCurrency : AmountActionUi() -} - -sealed class AmountAction : SendScreenAction { - data class SetAmount(val amountCrypto: BigDecimal, val isUserInput: Boolean) : AmountAction() - data class SetAmountError(val error: TapError?) : AmountAction() - data class SetDecimalSeparator(val separator: String) : AmountAction() - data class HideBalance(val hide: Boolean) : AmountAction() -} - -// Fee -sealed class FeeActionUi : SendScreenActionUi { - object ToggleControlsVisibility : FeeActionUi() - data class ChangeSelectedFee(val feeType: FeeType) : FeeActionUi() - class ChangeIncludeFee(val isIncluded: Boolean) : FeeActionUi() -} - -sealed class FeeAction : SendScreenAction { - - object RequestFee : FeeAction() - sealed class FeeCalculation : FeeAction() { - data class SetFeeResult(val fee: TransactionFee) : FeeCalculation() - object ClearResult : FeeCalculation() - } - - data class ChangeLayoutVisibility( - val main: Boolean? = null, - val controls: Boolean? = null, - val chipGroup: Boolean? = null, - ) : FeeAction() -} - -sealed class ReceiptAction : SendScreenAction { - object RefreshReceipt : ReceiptAction() -} - -sealed class SendActionUi : SendScreenActionUi { - data class SendAmountToRecipient(val messageForSigner: Message) : SendScreenActionUi - object CheckIfTransactionDataWasProvided : SendScreenActionUi -} - -sealed class SendAction : SendScreenAction { - - data class ChangeSendButtonState(val state: ButtonState) : SendAction() - object SendSuccess : SendAction(), ToastNotificationAction { - override val messageResource: Int = R.string.send_transaction_success - } - - sealed class Dialog : SendAction(), StateDialog { - data class TezosWarningDialog( - val reduceCallback: () -> Unit, - val sendAllCallback: () -> Unit, - val reduceAmount: BigDecimal, - ) : Dialog() - - data class KaspaWarningDialog( - val maxOutputs: Int, - val maxAmount: BigDecimal, - val onOk: () -> Unit, - ) : Dialog() - - data class ChiaWarningDialog( - val blockchainName: String, - val maxOutputs: Int, - val maxAmount: BigDecimal, - val onOk: () -> Unit, - ) : Dialog() - - sealed class SendTransactionFails : Dialog() { - data class CardSdkError(val error: TangemSdkError, val scanResponse: ScanResponse) : Dialog() - data class BlockchainSdkError( - val error: com.tangem.blockchain.common.BlockchainSdkError, - val scanResponse: ScanResponse, - ) : Dialog() - } - - data class RequestFeeError( - val error: com.tangem.blockchain.common.BlockchainSdkError, - val scanResponse: ScanResponse, - val onRetry: () -> Unit, - ) : Dialog() - - object Hide : Dialog() - } - - sealed class Warnings : SendAction() { - object Update : SendAction() - data class Set(val warningList: List) : SendAction() - } - - data class SendSpecificTransaction( - val sendAmount: String, - val destinationAddress: String, - val transactionId: String, - ) : SendAction() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AddressMiddleware.kt b/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AddressMiddleware.kt deleted file mode 100644 index 96cf7c9c6f..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AddressMiddleware.kt +++ /dev/null @@ -1,225 +0,0 @@ -package com.tangem.tap.features.send.redux.middlewares - -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Wallet -import com.tangem.blockchain.common.WalletManager -import com.tangem.core.analytics.Analytics -import com.tangem.tap.common.analytics.events.Token.Send.AddressEntered -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.send.redux.* -import com.tangem.tap.features.send.redux.AddressVerifyAction.AddressVerification.SetAddressError -import com.tangem.tap.features.send.redux.AddressVerifyAction.AddressVerification.SetWalletAddress -import com.tangem.tap.features.send.redux.AddressVerifyAction.Error -import com.tangem.tap.mainScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.rekotlin.Action -import org.rekotlin.DispatchFunction - -/** -[REDACTED_AUTHOR] - */ -internal class AddressMiddleware { - - private val addressValidator = AddressValidator() - - fun handle(action: AddressActionUi, appState: AppState?, dispatch: (Action) -> Unit) { - when (action) { - is AddressActionUi.HandleUserInput -> handleUserInput(action.data, appState, dispatch) - is AddressActionUi.PasteAddress -> pasteAddress(action.data, action.sourceType, dispatch) - is AddressActionUi.CheckClipboard -> verifyClipboard(action.data, appState, dispatch) - is AddressActionUi.CheckAddress -> verifyAddress(action.sourceType, appState, dispatch) - else -> return - } - } - - private fun handleUserInput(input: String, appState: AppState?, dispatch: DispatchFunction) { - val sendState = appState?.sendState ?: return - if (input == sendState.addressState.viewFieldValue.value) return - - setAddressAndCheck(data = input, sourceType = null, isUserInput = true, dispatch = dispatch) - } - - private fun pasteAddress(data: String, sourceType: AddressEntered.SourceType, dispatch: (Action) -> Unit) { - setAddressAndCheck(data = data, sourceType = sourceType, isUserInput = false, dispatch = dispatch) - } - - private fun setAddressAndCheck( - data: String, - sourceType: AddressEntered.SourceType?, - isUserInput: Boolean, - dispatch: (Action) -> Unit, - ) { - dispatch(SetWalletAddress(data, isUserInput)) - dispatch(AddressActionUi.CheckAddress(sourceType)) - } - - private fun verifyAddress( - sourceType: AddressEntered.SourceType?, - appState: AppState?, - dispatch: (Action) -> Unit, - ) { - val sendState = appState?.sendState ?: return - val walletManager = sendState.walletManager ?: return - val address = sendState.addressState.normalFieldValue ?: return - val isUserInput = sendState.addressState.viewFieldValue.isFromUserInput - - verifyAddress( - address = address, - walletManager = walletManager, - isUserInput = isUserInput, - dispatch = dispatch, - sourceType = sourceType, - ) - } - - private fun verifyAddress( - address: String, - walletManager: WalletManager, - sourceType: AddressEntered.SourceType?, - isUserInput: Boolean, - dispatch: (Action) -> Unit, - ) { - val wallet = walletManager.wallet - - mainScope.launch { - val failReason = withContext(Dispatchers.IO) { - addressValidator.validateAddress(walletManager, address) - } - - if (failReason == null) { - dispatchSuccessValidationActions( - address = address, - wallet = wallet, - sourceType = sourceType, - isUserInput = isUserInput, - dispatch = dispatch, - ) - } else { - dispatchFailedValidationActions( - failReason = failReason, - sourceType = sourceType, - dispatch = dispatch, - ) - } - } - } - - private fun dispatchSuccessValidationActions( - address: String, - wallet: Wallet, - sourceType: AddressEntered.SourceType?, - isUserInput: Boolean, - dispatch: (Action) -> Unit, - ) { - val addressSchemeSplit = when (wallet.blockchain) { - Blockchain.BitcoinCash, Blockchain.Kaspa -> listOf(address) - else -> address.split(":") - } - - val noSchemeAddress = when (addressSchemeSplit.size) { - 1 -> address // no scheme - 2 -> { // scheme - if (wallet.blockchain.validateShareScheme(addressSchemeSplit[0])) { - addressSchemeSplit[1] - } else { - sourceType?.let { - Analytics.send( - event = AddressEntered( - sourceType = sourceType, - validationResult = AddressEntered.ValidationResult.Fail, - ), - ) - } - dispatch(SetAddressError(Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN)) - return - } - } - else -> { // invalid URI - sourceType?.let { - Analytics.send( - event = AddressEntered( - sourceType = sourceType, - validationResult = AddressEntered.ValidationResult.Fail, - ), - ) - } - dispatch(SetAddressError(Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN)) - return - } - } - - val supposedAddress = noSchemeAddress.removeShareUriQuery() // TODO: parse query? - - noSchemeAddress.getQueryParameter("amount")?.toBigDecimalOrNull()?.let { - dispatch(AmountAction.SetAmount(it, false)) - dispatch(AmountActionUi.CheckAmountToSend) - } - dispatch(SetWalletAddress(supposedAddress, isUserInput)) - dispatch(TransactionExtrasAction.Prepare(wallet.blockchain, address, null)) - dispatch(FeeAction.RequestFee) - sourceType?.let { - Analytics.send( - event = AddressEntered( - sourceType = sourceType, - validationResult = AddressEntered.ValidationResult.Success, - ), - ) - } - } - - private fun dispatchFailedValidationActions( - failReason: Error, - sourceType: AddressEntered.SourceType?, - dispatch: (Action) -> Unit, - ) { - dispatch(SetAddressError(failReason)) - dispatch(TransactionExtrasAction.Release) - sourceType?.let { - Analytics.send( - event = AddressEntered( - sourceType = sourceType, - validationResult = AddressEntered.ValidationResult.Fail, - ), - ) - } - } - - private fun String.removeShareUriQuery(): String = this.substringBefore("?") - - private fun String.getQueryParameter(name: String): String? { - return this.substringAfter("?").splitToMap("&", "=")[name] - } - - private fun verifyClipboard(input: String?, appState: AppState?, dispatch: DispatchFunction) { - val address = input ?: return - val walletManager = appState?.sendState?.walletManager ?: return - - val internalDispatcher: (Action) -> Unit = { - when (it) { - is SetWalletAddress -> { - dispatch(AddressVerifyAction.ChangePasteBtnEnableState(true)) - } - is SetAddressError -> { - dispatch(AddressVerifyAction.ChangePasteBtnEnableState(false)) - } - } - } - - verifyAddress( - address = address, - walletManager = walletManager, - sourceType = null, - isUserInput = false, - dispatch = internalDispatcher, - ) - } -} - -fun String.splitToMap(firstDelimiter: String, secondDelimiter: String): Map { - return this - .split(firstDelimiter) - .map { it.split(secondDelimiter) } - .associate { it.first() to it.last() } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AddressValidator.kt b/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AddressValidator.kt deleted file mode 100644 index 195250f789..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AddressValidator.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.tangem.tap.features.send.redux.middlewares - -import com.tangem.blockchain.blockchains.near.NearWalletManager -import com.tangem.blockchain.blockchains.near.network.NearAccount -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Wallet -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchain.extensions.Result -import com.tangem.tap.features.send.redux.AddressVerifyAction - -internal class AddressValidator { - - suspend fun validateAddress(walletManager: WalletManager, address: String): AddressVerifyAction.Error? { - val blockchain = walletManager.wallet.blockchain - val wallet = walletManager.wallet - - return if (blockchain.isNear()) { - validateNearAddress(walletManager, address) - } else { - validateAddress(wallet, address) - } - } - - private suspend fun validateNearAddress( - walletManager: WalletManager, - address: String, - ): AddressVerifyAction.Error? { - // implicit address validation - if (address.length == NEAR_IMPLICIT_ADDRESS_LENGTH && hexRegex.matches(address)) { - return validateAddress(walletManager.wallet, address) - } - - // named address validation - if (address.length in NEAR_MIN_ADDRESS_LENGTH until NEAR_IMPLICIT_ADDRESS_LENGTH && - nearAddressRegex.matches(address) - ) { - val result = (walletManager as? NearWalletManager)?.getAccount(address) - return if (result is Result.Success && result.data is NearAccount.Full) { - null - } else { - AddressVerifyAction.Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN - } - } - - return AddressVerifyAction.Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN - } - - private fun Blockchain.isNear(): Boolean { - return this == Blockchain.Near || this == Blockchain.NearTestnet - } - - private fun validateAddress(wallet: Wallet, address: String): AddressVerifyAction.Error? { - return if (wallet.blockchain.validateAddress(address)) { - if (wallet.addresses.all { it.value != address }) { - null - } else { - AddressVerifyAction.Error.ADDRESS_SAME_AS_WALLET - } - } else { - AddressVerifyAction.Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN - } - } - - companion object { - private const val NEAR_MIN_ADDRESS_LENGTH = 2 - private const val NEAR_IMPLICIT_ADDRESS_LENGTH = 64 - private val hexRegex = Regex("^[0-9a-f]+$") - private val nearAddressRegex = Regex("^(([a-z\\d]+[\\-_])*[a-z\\d]+\\.)*([a-z\\d]+[\\-_])*[a-z\\d]+\$") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AmountMiddleware.kt b/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AmountMiddleware.kt deleted file mode 100644 index 0564218bcd..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/AmountMiddleware.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.tangem.tap.features.send.redux.middlewares - -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.TransactionError -import com.tangem.common.extensions.isZero -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.send.redux.AmountAction -import com.tangem.tap.features.send.redux.AmountActionUi -import com.tangem.tap.features.send.redux.FeeAction -import com.tangem.tap.features.send.redux.FeeActionUi -import com.tangem.tap.features.send.redux.ReceiptAction -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.features.send.redux.states.MainCurrencyType -import com.tangem.tap.features.send.redux.states.SendState -import org.rekotlin.Action -import java.math.BigDecimal -import java.util.* - -/** -[REDACTED_AUTHOR] - */ -class AmountMiddleware { - - fun handle(action: AmountActionUi, appState: AppState?, dispatch: (Action) -> Unit) { - when (action) { - is AmountActionUi.HandleUserInput -> handleUserInput(action.data, appState, dispatch) - is AmountActionUi.CheckAmountToSend -> checkAmountToSend(appState, dispatch) - is AmountActionUi.SetMaxAmount -> setMaxAmount(appState, dispatch) - is AmountActionUi.ToggleMainCurrency -> toggleMainCurrency(appState, dispatch) - is AmountActionUi.SetMainCurrency -> return - } - } - - private fun handleUserInput(data: String, appState: AppState?, dispatch: (Action) -> Unit) { - val sendState = appState?.sendState ?: return - val amountState = sendState.amountState - - var input = amountState.toBigDecimalSeparator(data) - input = if (input == ".") "0.0" else input - val inputValue = when { - input.isEmpty() || input == "0" -> BigDecimal.ZERO - else -> BigDecimal(input) - } - if (inputValue.isZero() && amountState.amountToSendCrypto.isZero()) return - - val inputValueCrypto = if (amountState.mainCurrency.type == MainCurrencyType.CRYPTO) { - inputValue - } else { - sendState.convertFiatToExtractCrypto(inputValue) - } - - dispatch(AmountAction.SetAmount(inputValueCrypto, true)) - dispatch(AmountActionUi.CheckAmountToSend) - dispatch(FeeAction.RequestFee) - } - - private fun checkAmountToSend(appState: AppState?, dispatch: (Action) -> Unit) { - val sendState = appState?.sendState ?: return - val walletManager = sendState.walletManager ?: return - val typedAmount = sendState.amountState.amountToExtract ?: return - - val inputCrypto = sendState.amountState.amountToSendCrypto - if (sendState.amountState.viewAmountValue.value == "0" && inputCrypto.isZero()) { - dispatch(ReceiptAction.RefreshReceipt) - dispatch(SendAction.ChangeSendButtonState(sendState.getButtonState())) - return - } - - val amountToSend = Amount(typedAmount, sendState.getTotalAmountToSend(inputCrypto)) - val transactionErrors = walletManager.validateTransaction(amountToSend, sendState.feeState.currentFee?.amount) - val amountFieldErrors = filterErrorsForAmountField(transactionErrors) - if (amountFieldErrors.isEmpty()) { - dispatch(AmountAction.SetAmountError(null)) - } else { - dispatch(AmountAction.SetAmountError(createValidateTransactionError(amountFieldErrors, walletManager))) - } - dispatch(ReceiptAction.RefreshReceipt) - dispatch(SendAction.ChangeSendButtonState(sendState.getButtonState())) - } - - private fun setMaxAmount(appState: AppState?, dispatch: (Action) -> Unit) { - val sendState = appState?.sendState ?: return - - dispatch(AmountAction.SetAmount(sendState.amountState.balanceCrypto, false)) - if (sendState.amountState.canIncludeFee()) { - dispatch(FeeAction.ChangeLayoutVisibility(main = true, controls = true)) - dispatch(FeeActionUi.ChangeIncludeFee(true)) - } - - dispatch(AmountActionUi.CheckAmountToSend) - if (SendState.isReadyToRequestFee()) dispatch(FeeAction.RequestFee) - } - - private fun toggleMainCurrency(appState: AppState?, dispatch: (Action) -> Unit) { - val amountState = appState?.sendState?.amountState ?: return - if (!appState.sendState.coinIsConvertible()) return - - val type = if (amountState.mainCurrency.type == MainCurrencyType.FIAT) { - MainCurrencyType.CRYPTO - } else { - MainCurrencyType.FIAT - } - - dispatch(AmountActionUi.SetMainCurrency(type)) - } -} - -private fun filterErrorsForAmountField(errors: EnumSet): EnumSet { - val showIntoAmountField = EnumSet.noneOf(TransactionError::class.java) - errors.forEach { - when (it) { - TransactionError.AmountExceedsBalance -> { - showIntoAmountField.remove(TransactionError.TotalExceedsBalance) - showIntoAmountField.add(it) - } - TransactionError.FeeExceedsBalance -> { - showIntoAmountField.remove(TransactionError.TotalExceedsBalance) - showIntoAmountField.add(it) - } - TransactionError.TotalExceedsBalance -> { - val notAcceptable = listOf(TransactionError.AmountExceedsBalance, TransactionError.FeeExceedsBalance) - if (!showIntoAmountField.containsAll(notAcceptable)) showIntoAmountField.add(it) - showIntoAmountField.remove(TransactionError.AmountLowerExistentialDeposit) - } - else -> showIntoAmountField.add(it) - } - } - showIntoAmountField.remove(TransactionError.TezosSendAll) - - return showIntoAmountField -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/RequestFeeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/RequestFeeMiddleware.kt deleted file mode 100644 index ea4092c252..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/RequestFeeMiddleware.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.tangem.tap.features.send.redux.middlewares - -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.BlockchainSdkError -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.blockchain.extensions.Result -import com.tangem.common.extensions.isZero -import com.tangem.domain.demo.DemoTransactionSender -import com.tangem.domain.feedback.models.BlockchainErrorInfo -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.extensions.stripZeroPlainString -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.demo.isDemoCard -import com.tangem.tap.features.send.redux.AmountActionUi -import com.tangem.tap.features.send.redux.FeeAction -import com.tangem.tap.features.send.redux.ReceiptAction -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.features.send.redux.states.SendState -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.rekotlin.DispatchFunction -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -class RequestFeeMiddleware { - - @Suppress("CyclomaticComplexMethod") - fun handle(appState: AppState?, dispatch: DispatchFunction) { - val sendState = appState?.sendState ?: return - val walletManager = sendState.walletManager ?: return - val scanResponse = appState.globalState.scanResponse ?: return - - if (!SendState.isReadyToRequestFee()) { - dispatch(FeeAction.FeeCalculation.ClearResult) - dispatch(ReceiptAction.RefreshReceipt) - dispatch(SendAction.ChangeSendButtonState(sendState.getButtonState())) - return - } - val typedAmount = sendState.amountState.amountToExtract ?: return - - val destinationAddress = sendState.addressState.destinationWalletAddress!! - val destinationAmount = Amount(typedAmount, sendState.amountState.amountToSendCrypto) - val txSender = if (scanResponse.isDemoCard()) { - DemoTransactionSender(walletManager) - } else { - walletManager - } - scope.launch { - val feeResult = txSender.getFee(destinationAmount, destinationAddress) - withContext(Dispatchers.Main) { - when (feeResult) { - is Result.Success -> { - val result = feeResult.data -// val result = FeeMock.getFee(walletManager.wallet.blockchain) - dispatch(FeeAction.FeeCalculation.SetFeeResult(result)) - when (result) { - is TransactionFee.Single -> { - val fee = result.normal.amount.value ?: BigDecimal.ZERO - if (fee.isZero()) { - dispatch(FeeAction.ChangeLayoutVisibility(main = false)) - } else { - dispatch(FeeAction.ChangeLayoutVisibility(main = true, chipGroup = false)) - } - } - is TransactionFee.Choosable -> { - dispatch(FeeAction.ChangeLayoutVisibility(main = true, chipGroup = true)) - } - } - } - is Result.Failure -> { - dispatch(FeeAction.FeeCalculation.ClearResult) - dispatch(FeeAction.ChangeLayoutVisibility(main = false)) - - val featureToggles = store.inject(DaggerGraphState::feedbackManagerFeatureToggles) - if (featureToggles.isLocalLogsEnabled) { - saveBlockchainError(feeResult, destinationAddress, destinationAmount, walletManager) - } else { - store.state.globalState.feedbackManager?.infoHolder?.updateOnSendError( - walletManager = walletManager, - amountToSend = destinationAmount, - feeAmount = null, - destinationAddress = destinationAddress, - ) - } - - val blockchainSdkError = feeResult.error as? BlockchainSdkError ?: return@withContext - dispatch( - SendAction.Dialog.RequestFeeError( - error = blockchainSdkError, - scanResponse = scanResponse, - onRetry = { dispatch(FeeAction.RequestFee) }, - ), - ) - } - } - dispatch(AmountActionUi.CheckAmountToSend) - } - } - } - - private fun saveBlockchainError( - feeResult: Result.Failure, - destinationAddress: String, - destinationAmount: Amount, - walletManager: WalletManager, - ) { - store.inject(DaggerGraphState::saveBlockchainErrorUseCase).invoke( - error = BlockchainErrorInfo( - errorMessage = (feeResult.error as? BlockchainSdkError)?.customMessage - ?: "It isn't BlockchainSdkError", - blockchainId = walletManager.wallet.blockchain.id, - derivationPath = walletManager.wallet.publicKey.derivationPath?.rawPath ?: "", - destinationAddress = destinationAddress, - tokenSymbol = if (destinationAmount.type is AmountType.Token) { - destinationAmount.currencySymbol - } else { - "" - }, - amount = destinationAmount.value?.stripZeroPlainString() ?: "0", - fee = null, - ), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/SendMiddleware.kt b/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/SendMiddleware.kt deleted file mode 100644 index ecb0e071d8..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/SendMiddleware.kt +++ /dev/null @@ -1,455 +0,0 @@ -package com.tangem.tap.features.send.redux.middlewares - -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.tangem.blockchain.blockchains.algorand.AlgorandTransactionExtras -import com.tangem.blockchain.blockchains.binance.BinanceTransactionExtras -import com.tangem.blockchain.blockchains.cosmos.CosmosTransactionExtras -import com.tangem.blockchain.blockchains.hedera.HederaTransactionExtras -import com.tangem.blockchain.blockchains.polkadot.ExistentialDepositProvider -import com.tangem.blockchain.blockchains.stellar.StellarTransactionExtras -import com.tangem.blockchain.blockchains.ton.TonTransactionExtras -import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder.XrpTransactionExtras -import com.tangem.blockchain.common.* -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.extensions.Result -import com.tangem.blockchainsdk.utils.minimalAmount -import com.tangem.common.core.TangemSdkError -import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.Analytics -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic -import com.tangem.domain.common.TapWorkarounds.isStart2Coin -import com.tangem.domain.common.extensions.withMainContext -import com.tangem.domain.demo.DemoTransactionSender -import com.tangem.domain.feedback.models.BlockchainErrorInfo -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.tokens.legacy.TradeCryptoAction -import com.tangem.tap.common.analytics.events.Token -import com.tangem.tap.common.analytics.events.Token.Send.SelectedCurrency.CurrencyType -import com.tangem.tap.common.extensions.* -import com.tangem.tap.common.redux.AppDialog -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.TangemSigner -import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.configurable.warningMessage.WarningMessage -import com.tangem.tap.features.demo.isDemoCard -import com.tangem.tap.features.send.redux.* -import com.tangem.tap.features.send.redux.FeeAction.RequestFee -import com.tangem.tap.features.send.redux.states.* -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE -import com.tangem.wallet.R -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware -import java.util.EnumSet - -/** -[REDACTED_AUTHOR] - */ -class SendMiddleware { - val sendMiddleware: Middleware = { dispatch, appState -> - { nextDispatch -> - { action -> - when (action) { - is AddressActionUi -> AddressMiddleware().handle(action, appState(), dispatch) - is AmountActionUi -> AmountMiddleware().handle(action, appState(), dispatch) - is RequestFee -> RequestFeeMiddleware().handle(appState(), dispatch) - is SendActionUi.SendAmountToRecipient -> - verifyAndSendTransaction(action, appState(), dispatch) - is SendAction.Warnings.Update -> updateWarnings(dispatch) - is SendActionUi.CheckIfTransactionDataWasProvided -> { - val transactionData = appState()?.sendState?.externalTransactionData - if (transactionData != null) { - store.dispatchOnMain( - AddressVerifyAction.AddressVerification.SetWalletAddress( - address = transactionData.destinationAddress, - isUserInput = false, - ), - ) - store.dispatchOnMain(AmountActionUi.SetMainCurrency(MainCurrencyType.CRYPTO)) - store.dispatchOnMain(AmountActionUi.HandleUserInput(transactionData.amount)) - store.dispatchOnMain( - AmountAction.SetAmount( - transactionData.amount.toBigDecimal(), - false, - ), - ) - } - } - } - nextDispatch(action) - } - } - } -} - -private fun verifyAndSendTransaction( - action: SendActionUi.SendAmountToRecipient, - appState: AppState?, - dispatch: (Action) -> Unit, -) { - val sendState = appState?.sendState ?: return - val walletManager = sendState.walletManager ?: return - val card = appState.globalState.scanResponse?.card ?: return - val destinationAddress = sendState.addressState.destinationWalletAddress ?: return - val typedAmount = sendState.amountState.amountToExtract ?: return - val fee = sendState.feeState.currentFee ?: return - - val amountToSend = Amount(typedAmount, sendState.getTotalAmountToSend()) - - val transactionErrors = walletManager.validateTransaction(amountToSend, fee.amount) - when { - transactionErrors.contains(TransactionError.TezosSendAll) -> { - val reduceAmount = walletManager.wallet.blockchain.minimalAmount() - dispatch( - SendAction.Dialog.TezosWarningDialog( - reduceCallback = { - dispatch(AmountAction.SetAmount(typedAmount.value!!.minus(reduceAmount), false)) - dispatch(AmountActionUi.CheckAmountToSend) - }, - sendAllCallback = { - sendTransaction( - action = action, - walletManager = walletManager, - amountToSend = amountToSend, - fee = fee, - feeType = sendState.feeState.selectedFeeType, - destinationAddress = destinationAddress, - transactionExtras = sendState.transactionExtrasState, - card = card, - externalTransactionData = sendState.externalTransactionData, - mainCurrencyType = sendState.amountState.mainCurrency.type, - dispatch = dispatch, - ) - }, - reduceAmount, - ), - ) - } - else -> { - sendTransaction( - action = action, - walletManager = walletManager, - amountToSend = amountToSend, - fee = fee, - feeType = sendState.feeState.selectedFeeType, - destinationAddress = destinationAddress, - transactionExtras = sendState.transactionExtrasState, - card = card, - externalTransactionData = sendState.externalTransactionData, - mainCurrencyType = sendState.amountState.mainCurrency.type, - dispatch = dispatch, - ) - } - } -} - -@Suppress("LongParameterList", "LongMethod", "ComplexMethod") -private fun sendTransaction( - action: SendActionUi.SendAmountToRecipient, - walletManager: WalletManager, - amountToSend: Amount, - fee: Fee, - feeType: FeeType, - destinationAddress: String, - transactionExtras: TransactionExtrasState, - card: CardDTO, - externalTransactionData: ExternalTransactionData?, - mainCurrencyType: MainCurrencyType, - dispatch: (Action) -> Unit, -) { - dispatch(SendAction.ChangeSendButtonState(ButtonState.PROGRESS)) - var txData = walletManager.createTransaction(amountToSend, fee, destinationAddress) - - transactionExtras.xlmMemo?.memo?.let { txData = txData.copy(extras = StellarTransactionExtras(it)) } - transactionExtras.binanceMemo?.memo?.let { txData = txData.copy(extras = BinanceTransactionExtras(it.toString())) } - transactionExtras.xrpDestinationTag?.tag?.let { txData = txData.copy(extras = XrpTransactionExtras(it)) } - transactionExtras.cosmosMemoState?.memo?.let { txData = txData.copy(extras = CosmosTransactionExtras(it)) } - transactionExtras.tonMemoState?.memo?.let { txData = txData.copy(extras = TonTransactionExtras(it)) } - transactionExtras.hederaMemoState?.memo?.let { txData = txData.copy(extras = HederaTransactionExtras(it)) } - transactionExtras.algorandMemoState?.memo?.let { txData = txData.copy(extras = AlgorandTransactionExtras(it)) } - - scope.launch { - // TODO: Risky commented this part, unknown logic, need to test if removed - // TODO: [REDACTED_JIRA] - // val updateWalletResult = walletManager.safeUpdate() - // if (updateWalletResult is Result.Failure) { - // withMainContext { - // when (val error = updateWalletResult.error) { - // is TapError -> store.dispatchErrorNotification(error) - // is BlockchainSdkError -> { - // updateFeedbackManagerInfo( - // walletManager = walletManager, - // amountToSend = amountToSend, - // feeAmount = fee.amount, - // destinationAddress = destinationAddress, - // ) - // dispatch(SendAction.Dialog.SendTransactionFails.BlockchainSdkError(error = error)) - // } - // else -> { - // val tapError = if (error.message == null) { - // TapError.UnknownError - // } else { - // TapError.CustomError(error.message!!) - // } - // store.dispatchErrorNotification(tapError) - // } - // } - // dispatch(SendAction.ChangeSendButtonState(ButtonState.ENABLED)) - // } - // return@launch - // } - - val tangemSdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk - val linkedTerminalState = tangemSdk.config.linkedTerminal - if (card.isStart2Coin) { - tangemSdk.config.linkedTerminal = false - } - - val signer = TangemSigner( - card = card, - tangemSdk = tangemSdk, - initialMessage = action.messageForSigner, - ) { signResponse -> - store.dispatch( - GlobalAction.UpdateWalletSignedHashes( - walletSignedHashes = signResponse.totalSignedHashes, - walletPublicKey = walletManager.wallet.publicKey.seedKey, - remainingSignatures = signResponse.remainingSignatures, - ), - ) - } - val sendResult = try { - if (card.isDemoCard()) { - DemoTransactionSender(walletManager).send(txData, signer) - } else { - (walletManager as TransactionSender).send(txData, signer) - } - } catch (ex: Exception) { - FirebaseCrashlytics.getInstance().recordException(ex) - delay(DELAY_SDK_DIALOG_CLOSE) - withMainContext { - tangemSdk.config.linkedTerminal = linkedTerminalState - dispatch(SendAction.ChangeSendButtonState(ButtonState.ENABLED)) - store.dispatchErrorNotification(TapError.CustomError(ex.localizedMessage ?: "Unknown error")) - } - return@launch - } - - withMainContext { - dispatch(SendAction.ChangeSendButtonState(ButtonState.ENABLED)) - tangemSdk.config.linkedTerminal = linkedTerminalState - - when (sendResult) { - is Result.Success -> { - dispatch(SendAction.SendSuccess) - - if (externalTransactionData != null) { - Analytics.send( - Basic.TransactionSent( - sentFrom = AnalyticsParam.TxSentFrom.Sell, - memoType = getMemoType(transactionExtras), - ), - ) - Analytics.sendSelectedCurrencyEvent(mainCurrencyType) - dispatch(TradeCryptoAction.FinishSelling(externalTransactionData.transactionId)) - } else { - Analytics.send( - Basic.TransactionSent( - sentFrom = AnalyticsParam.TxSentFrom.Send( - blockchain = walletManager.wallet.blockchain.fullName, - token = amountToSend.currencySymbol, - feeType = feeType.convertToAnalyticsFeeType(), - ), - memoType = getMemoType(transactionExtras), - ), - ) - Analytics.sendSelectedCurrencyEvent(mainCurrencyType) - store.dispatchNavigationAction(AppRouter::pop) - } - } - is Result.Failure -> { - updateFeedbackManagerInfo( - sendResult = sendResult.error, - walletManager = walletManager, - amountToSend = amountToSend, - feeAmount = fee.amount, - destinationAddress = destinationAddress, - ) - val error = sendResult.error as? BlockchainSdkError ?: return@withMainContext - - when (error) { - is BlockchainSdkError.WrappedTangemError -> { - val tangemSdkError = error.tangemError as? TangemSdkError ?: return@withMainContext - if (tangemSdkError is TangemSdkError.UserCancelled) return@withMainContext - - dispatch( - SendAction.Dialog.SendTransactionFails.CardSdkError( - error = tangemSdkError, - scanResponse = store.inject(DaggerGraphState::generalUserWalletsListManager) - .selectedUserWalletSync?.scanResponse - ?: error("ScanResponse must be not null"), - ), - ) - } - is BlockchainSdkError.CreateAccountUnderfunded -> { - // from XLM, XRP, Polkadot - dispatch( - SendAction.Dialog.SendTransactionFails.BlockchainSdkError( - error = error, - scanResponse = store.inject(DaggerGraphState::generalUserWalletsListManager) - .selectedUserWalletSync?.scanResponse - ?: error("ScanResponse must be not null"), - ), - ) - } - is BlockchainSdkError.Kaspa.UtxoAmountError -> { - dispatch( - SendAction.Dialog.KaspaWarningDialog( - maxOutputs = error.maxOutputs, - maxAmount = error.maxAmount, - onOk = { - dispatch(AmountAction.SetAmount(error.maxAmount, isUserInput = false)) - dispatch(AmountActionUi.CheckAmountToSend) - }, - ), - ) - } - is BlockchainSdkError.Chia.UtxoAmountError -> { - dispatch( - SendAction.Dialog.ChiaWarningDialog( - blockchainName = Blockchain.Chia.fullName, - maxOutputs = error.maxOutputs, - maxAmount = error.maxAmount, - onOk = { - dispatch(AmountAction.SetAmount(error.maxAmount, isUserInput = false)) - dispatch(AmountActionUi.CheckAmountToSend) - }, - ), - ) - } - else -> { - when { - error.customMessage.contains(DemoTransactionSender.ID) -> { - store.dispatchDialogShow( - AppDialog.SimpleOkDialogRes( - headerId = R.string.common_done, - messageId = R.string.alert_demo_feature_disabled, - onOk = { store.dispatchNavigationAction(AppRouter::pop) }, - ), - ) - } - else -> { - dispatch( - SendAction.Dialog.SendTransactionFails.BlockchainSdkError( - error = error, - scanResponse = store.inject(DaggerGraphState::generalUserWalletsListManager) - .selectedUserWalletSync?.scanResponse - ?: error("ScanResponse must be not null"), - ), - ) - } - } - } - } - } - } - } - } -} - -private fun getMemoType(transactionExtras: TransactionExtrasState): Basic.TransactionSent.MemoType { - return when { - transactionExtras.isEmpty() -> Basic.TransactionSent.MemoType.Empty - transactionExtras.isNull() -> Basic.TransactionSent.MemoType.Null - else -> Basic.TransactionSent.MemoType.Full - } -} - -private fun Analytics.sendSelectedCurrencyEvent(mainCurrencyType: MainCurrencyType) { - send( - Token.Send.SelectedCurrency( - currency = when (mainCurrencyType) { - MainCurrencyType.FIAT -> CurrencyType.AppCurrency - MainCurrencyType.CRYPTO -> CurrencyType.Token - }, - ), - ) -} - -private fun updateFeedbackManagerInfo( - walletManager: WalletManager, - amountToSend: Amount, - feeAmount: Amount, - destinationAddress: String, - sendResult: BlockchainError, -) { - val featureToggles = store.inject(DaggerGraphState::feedbackManagerFeatureToggles) - if (featureToggles.isLocalLogsEnabled) { - store.inject(DaggerGraphState::saveBlockchainErrorUseCase).invoke( - error = BlockchainErrorInfo( - errorMessage = (sendResult as? BlockchainSdkError)?.customMessage ?: "It isn't BlockchainSdkError", - blockchainId = walletManager.wallet.blockchain.id, - derivationPath = walletManager.wallet.publicKey.derivationPath?.rawPath ?: "", - destinationAddress = destinationAddress, - tokenSymbol = if (amountToSend.type is AmountType.Token) { - amountToSend.currencySymbol - } else { - "" - }, - amount = amountToSend.value?.stripZeroPlainString() ?: "0", - fee = feeAmount.value?.stripZeroPlainString() ?: "0", - ), - ) - } else { - store.state.globalState.feedbackManager?.infoHolder?.updateOnSendError( - walletManager = walletManager, - amountToSend = amountToSend, - feeAmount = feeAmount, - destinationAddress = destinationAddress, - ) - } -} - -fun createValidateTransactionError( - errorList: EnumSet, - walletManager: WalletManager, -): TapError.ValidateTransactionErrors { - val tapErrors = errorList.map { - when (it) { - TransactionError.AmountExceedsBalance -> TapError.AmountExceedsBalance - TransactionError.AmountLowerExistentialDeposit -> { - if (walletManager is ExistentialDepositProvider) { - val args = listOf(walletManager.getExistentialDeposit().stripZeroPlainString()) - TapError.AmountLowerExistentialDeposit(args) - } else { - TapError.UnknownError - } - } - TransactionError.FeeExceedsBalance -> TapError.FeeExceedsBalance - TransactionError.TotalExceedsBalance -> TapError.TotalExceedsBalance - TransactionError.InvalidAmountValue -> TapError.InvalidAmountValue - TransactionError.InvalidFeeValue -> TapError.InvalidFeeValue - TransactionError.DustAmount -> { - val args = listOf(walletManager.dustValue?.stripZeroPlainString() ?: "0") - TapError.DustAmount(args) - } - TransactionError.DustChange -> TapError.DustChange - else -> TapError.UnknownError - } - } - return TapError.ValidateTransactionErrors(tapErrors) { it.joinToString("\r\n") } -} - -private fun updateWarnings(dispatch: (Action) -> Unit) { - val warningsManager = store.state.globalState.warningManager ?: return - val blockchain = store.state.sendState.walletManager?.wallet?.blockchain ?: return - - val warnings = warningsManager.getWarnings(WarningMessage.Location.SendScreen, listOf(blockchain)) - dispatch(SendAction.Warnings.Set(warnings)) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/AddressReducer.kt b/app/src/main/java/com/tangem/tap/features/send/redux/reducers/AddressReducer.kt deleted file mode 100644 index a5b44bff0f..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/AddressReducer.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tangem.tap.features.send.redux.reducers - -import com.tangem.tap.features.send.redux.AddressActionUi -import com.tangem.tap.features.send.redux.AddressVerifyAction -import com.tangem.tap.features.send.redux.AddressVerifyAction.AddressVerification -import com.tangem.tap.features.send.redux.SendScreenAction -import com.tangem.tap.features.send.redux.states.AddressState -import com.tangem.tap.features.send.redux.states.InputViewValue -import com.tangem.tap.features.send.redux.states.SendState - -/** -[REDACTED_AUTHOR] - */ -class AddressReducer : SendInternalReducer { - override fun handle(action: SendScreenAction, sendState: SendState): SendState = when (action) { - is AddressActionUi -> handleUiAction(action, sendState, sendState.addressState) - is AddressVerifyAction -> handleAction(action, sendState, sendState.addressState) - else -> sendState - } - - private fun handleUiAction(action: AddressActionUi, sendState: SendState, state: AddressState): SendState { - val result = when (action) { - is AddressActionUi.HandleUserInput -> state - is AddressActionUi.SetTruncateHandler -> state.copy(truncateHandler = action.handler) - is AddressActionUi.TruncateOrRestore -> { - val value = if (action.truncate) state.truncatedFieldValue ?: "" else state.normalFieldValue ?: "" - state.copy(viewFieldValue = state.viewFieldValue.copy(value = value)) - } - is AddressActionUi.PasteAddress -> return sendState - is AddressActionUi.CheckClipboard -> return sendState - is AddressActionUi.CheckAddress -> return sendState - } - return updateLastState(sendState.copy(addressState = result), result) - } - - private fun handleAction(action: AddressVerifyAction, sendState: SendState, state: AddressState): SendState { - val result = when (action) { - is AddressVerification.SetWalletAddress -> { - state.copy( - viewFieldValue = InputViewValue(action.address, action.isUserInput), - normalFieldValue = action.address, - truncatedFieldValue = state.truncate(action.address), - destinationWalletAddress = action.address, - error = null, - ) - } - is AddressVerifyAction.ChangePasteBtnEnableState -> state.copy(pasteIsEnabled = action.isEnabled) - is AddressVerification.SetAddressError -> state.copy(error = action.error, destinationWalletAddress = null) - } - return updateLastState(sendState.copy(addressState = result), result) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/AmountReducer.kt b/app/src/main/java/com/tangem/tap/features/send/redux/reducers/AmountReducer.kt deleted file mode 100644 index 645fc6ec32..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/AmountReducer.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.tangem.tap.features.send.redux.reducers - -import com.tangem.common.extensions.isZero -import com.tangem.tap.common.extensions.stripZeroPlainString -import com.tangem.tap.features.send.redux.AmountAction -import com.tangem.tap.features.send.redux.AmountActionUi -import com.tangem.tap.features.send.redux.AmountActionUi.SetMainCurrency -import com.tangem.tap.features.send.redux.SendScreenAction -import com.tangem.tap.features.send.redux.states.AmountState -import com.tangem.tap.features.send.redux.states.InputViewValue -import com.tangem.tap.features.send.redux.states.MainCurrencyType -import com.tangem.tap.features.send.redux.states.SendState -import com.tangem.utils.StringsSigns.STARS -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -class AmountReducer : SendInternalReducer { - override fun handle(action: SendScreenAction, sendState: SendState): SendState = when (action) { - is AmountActionUi -> handleUiAction(action, sendState, sendState.amountState) - is AmountAction -> handleAction(action, sendState, sendState.amountState) - else -> sendState - } - - private fun handleUiAction(action: AmountActionUi, sendState: SendState, state: AmountState): SendState { - val result = when (action) { - is SetMainCurrency -> { - val currencyCanBeSwitched = sendState.mainCurrencyCanBeSwitched() - - when (val currency = if (currencyCanBeSwitched) action.mainCurrency else MainCurrencyType.CRYPTO) { - MainCurrencyType.FIAT -> { - val fiatToSend = if (state.amountToSendCrypto.isZero()) { - BigDecimal.ZERO - } else { - sendState.convertExtractCryptoToFiat(state.amountToSendCrypto) - } - val rescaledBalance = sendState.convertExtractCryptoToFiat(state.balanceCrypto, true) - val viewValue = state.restoreDecimalSeparator(fiatToSend.stripZeroPlainString()) - state.copy( - viewAmountValue = InputViewValue(viewValue), - viewBalanceValue = if (state.hideBalance) STARS else rescaledBalance.stripZeroPlainString(), - mainCurrency = state.createMainCurrency(currency, true), - maxLengthOfAmount = sendState.getDecimals(currency), - cursorAtTheSamePosition = false, - ) - } - MainCurrencyType.CRYPTO -> { - val viewValue = state.restoreDecimalSeparator(state.amountToSendCrypto.stripZeroPlainString()) - state.copy( - viewAmountValue = InputViewValue(viewValue), - viewBalanceValue = state.balanceCrypto.stripZeroPlainString(), - mainCurrency = state.createMainCurrency(currency, currencyCanBeSwitched), - maxLengthOfAmount = sendState.getDecimals(currency), - cursorAtTheSamePosition = false, - ) - } - } - } - else -> return sendState - } - - return updateLastState(sendState.copy(amountState = result), result) - } - - private fun handleAction(action: AmountAction, sendState: SendState, state: AmountState): SendState { - val result = when (action) { - is AmountAction.SetAmount -> { - val amount = if (state.mainCurrency.type == MainCurrencyType.CRYPTO) { - action.amountCrypto - } else { - sendState.convertExtractCryptoToFiat(action.amountCrypto, true) - } - val viewValue = state.restoreDecimalSeparator(amount.stripZeroPlainString()) - state.copy( - viewAmountValue = InputViewValue(viewValue, action.isUserInput), - amountToSendCrypto = action.amountCrypto, - cursorAtTheSamePosition = true, - error = null, - ) - } - is AmountAction.SetAmountError -> state.copy(error = action.error) - is AmountAction.SetDecimalSeparator -> state.copy(decimalSeparator = action.separator) - is AmountAction.HideBalance -> { - val rescaledBalance = if (state.mainCurrency.type == MainCurrencyType.CRYPTO) { - state.balanceCrypto - } else { - sendState.convertExtractCryptoToFiat(state.balanceCrypto, true) - } - - state.copy( - hideBalance = action.hide, - viewBalanceValue = if (action.hide) STARS else rescaledBalance.stripZeroPlainString(), - ) - } - } - return updateLastState(sendState.copy(amountState = result), result) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/FeeReducer.kt b/app/src/main/java/com/tangem/tap/features/send/redux/reducers/FeeReducer.kt deleted file mode 100644 index e4ac2c2376..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/FeeReducer.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.tangem.tap.features.send.redux.reducers - -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.tap.common.entities.ProgressState -import com.tangem.tap.features.send.redux.FeeAction -import com.tangem.tap.features.send.redux.FeeActionUi -import com.tangem.tap.features.send.redux.SendScreenAction -import com.tangem.tap.features.send.redux.states.FeeState -import com.tangem.tap.features.send.redux.states.FeeType -import com.tangem.tap.features.send.redux.states.SendState - -/** -[REDACTED_AUTHOR] - */ -class FeeReducer : SendInternalReducer { - - override fun handle(action: SendScreenAction, sendState: SendState): SendState = when (action) { - is FeeActionUi -> handleUiAction(action, sendState, sendState.feeState) - is FeeAction -> handleAction(action, sendState, sendState.feeState) - else -> sendState - } - - private fun handleUiAction(action: FeeActionUi, sendState: SendState, state: FeeState): SendState { - val result = when (action) { - is FeeActionUi.ToggleControlsVisibility -> { - state.copy(controlsLayoutIsVisible = !state.controlsLayoutIsVisible) - } - is FeeActionUi.ChangeSelectedFee -> { - val currentFee = state.fees?.let { - createValueOfFeeAmount(action.feeType, it) - } - state.copy( - selectedFeeType = action.feeType, - currentFee = currentFee, - ) - } - is FeeActionUi.ChangeIncludeFee -> state.copy(feeIsIncluded = action.isIncluded) - } - return updateLastState(sendState.copy(feeState = result), result) - } - - private fun handleAction(action: FeeAction, sendState: SendState, state: FeeState): SendState { - val result = when (action) { - is FeeAction.RequestFee -> { - state.copy(progressState = ProgressState.Loading) - } - is FeeAction.ChangeLayoutVisibility -> { - fun getVisibility(current: Boolean, proposed: Boolean?): Boolean = proposed ?: current - state.copy( - mainLayoutIsVisible = getVisibility(state.mainLayoutIsVisible, action.main), - controlsLayoutIsVisible = getVisibility(state.controlsLayoutIsVisible, action.controls), - feeChipGroupIsVisible = getVisibility(state.feeChipGroupIsVisible, action.chipGroup), - ) - } - is FeeAction.FeeCalculation.SetFeeResult -> { - when (val fees = action.fee) { - is TransactionFee.Single -> { - val feeType = FeeType.SINGLE - val currentFee = createValueOfFeeAmount(feeType, fees) - - state.copy( - selectedFeeType = feeType, - fees = fees, - currentFee = currentFee, - feeIsApproximate = isFeeApproximate(sendState), - ) - } - is TransactionFee.Choosable -> { - val feeType = getCurrentFeeType(state) - val currentFee = createValueOfFeeAmount(feeType, fees) - - state.copy( - selectedFeeType = feeType, - fees = fees, - currentFee = currentFee, - feeIsApproximate = isFeeApproximate(sendState), - ) - } - }.copy( - progressState = ProgressState.Done, - ) - } - FeeAction.FeeCalculation.ClearResult -> { - state.copy( - fees = null, - currentFee = null, - progressState = ProgressState.Done, - ) - } - } - - return updateLastState(sendState.copy(feeState = result), result) - } - - private fun createValueOfFeeAmount(feeType: FeeType, transactionFee: TransactionFee): Fee { - return when (transactionFee) { - is TransactionFee.Single -> { - transactionFee.normal - } - is TransactionFee.Choosable -> { - when (feeType) { - FeeType.SINGLE -> transactionFee.normal - FeeType.LOW -> transactionFee.minimum - FeeType.NORMAL -> transactionFee.normal - FeeType.PRIORITY -> transactionFee.priority - } - } - } - } - - private fun isFeeApproximate(sendState: SendState): Boolean { - val blockchain = sendState.walletManager?.wallet?.blockchain ?: return false - - val amountType = sendState.amountState.typeOfAmount - return blockchain.isFeeApproximate(amountType) - } - - private fun getCurrentFeeType(state: FeeState): FeeType { - return if (state.selectedFeeType == FeeType.SINGLE) FeeType.NORMAL else state.selectedFeeType - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/ReceiptReducer.kt b/app/src/main/java/com/tangem/tap/features/send/redux/reducers/ReceiptReducer.kt deleted file mode 100644 index 8d1d46d4cd..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/ReceiptReducer.kt +++ /dev/null @@ -1,408 +0,0 @@ -package com.tangem.tap.features.send.redux.reducers - -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.FeePaidCurrency -import com.tangem.blockchain.common.Wallet -import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.tap.common.extensions.scaleToFiat -import com.tangem.tap.common.extensions.stripZeroPlainString -import com.tangem.tap.features.send.redux.ReceiptAction.RefreshReceipt -import com.tangem.tap.features.send.redux.SendScreenAction -import com.tangem.tap.features.send.redux.states.* -import com.tangem.tap.store -import com.tangem.utils.StringsSigns.LOWER_SIGN -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -@Suppress("LargeClass") -class ReceiptReducer : SendInternalReducer { - - private lateinit var sendState: SendState - private lateinit var amountState: AmountState - private lateinit var feeState: FeeState - - override fun handle(action: SendScreenAction, sendState: SendState): SendState { - this.sendState = sendState - this.amountState = sendState.amountState - this.feeState = sendState.feeState - - return when (action) { - is RefreshReceipt -> handleRefresh(sendState.receiptState) - else -> sendState - } - } - - private fun handleRefresh(state: ReceiptState): SendState { - val wallet = sendState.walletManager?.wallet ?: return sendState - val feePaidCurrency = wallet.blockchain.feePaidCurrency() - - val layoutType = determineLayoutType(amountState.mainCurrency.type, amountState.typeOfAmount, feePaidCurrency) - val symbols = determineSymbols(wallet, amountState.typeOfAmount, feePaidCurrency) - val showBlank = !SendState.isReadyToSend() - val result = state.copy( - visibleTypeOfReceipt = layoutType, - mainCurrency = amountState.mainCurrency, - fiat = createFiatType(symbols, showBlank), - crypto = createCryptoType(symbols, showBlank), - tokenFiat = createTokenFiatType(symbols, showBlank), - tokenCrypto = createTokenCryptoType(symbols, showBlank), - customTokenFiat = createCustomTokenFiat(symbols, showBlank), - customTokenCrypto = createCustomTokenCrypto(symbols, showBlank), - sameCurrencyFiat = createSameCurrencyFiat(symbols, showBlank), - sameCurrencyCrypto = createSameCurrencyCrypto(symbols, showBlank), - ) - return updateLastState(sendState.copy(receiptState = result), result) - } - - private fun createFiatType(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptFiat { - val feeCrypto = feeState.getCurrentFeeValue() - val feeFiat = convertToFiatPrecision(feeCrypto) - - if (showBlank) { - return ReceiptFiat("0", feeFiat, "0", "0", symbols) - } - - return if (feeState.feeIsIncluded) { - val amountFiat = convertToFiatPrecision(amountState.amountToSendCrypto.minus(feeCrypto)) - val totalFiat = convertToFiatPrecision(amountState.amountToSendCrypto) - ReceiptFiat( - amountFiat = amountFiat, - feeFiat = feeFiat, - totalFiat = totalFiat, - willSentCrypto = amountState.amountToSendCrypto.stripZeroPlainString(), - symbols = symbols, - ) - } else { - val totalAmountCrypto = amountState.amountToSendCrypto.plus(feeCrypto) - val amountFiat = convertToFiatPrecision(amountState.amountToSendCrypto) - val totalFiat = convertToFiatPrecision(totalAmountCrypto) - ReceiptFiat( - amountFiat = amountFiat, - feeFiat = feeFiat, - totalFiat = totalFiat, - willSentCrypto = totalAmountCrypto.stripZeroPlainString(), - symbols = symbols, - ) - } - } - - private fun createCryptoType(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptCrypto { - val feeCrypto = feeState.getCurrentFeeValue() - val feeFiat = convertToFiatPrecision(feeCrypto) - if (showBlank) { - return ReceiptCrypto("0", feeCrypto.stripZeroPlainString(), "0", "0", "0", symbols) - } - - if (feeState.feeIsIncluded) { - return ReceiptCrypto( - amountCrypto = amountState.amountToSendCrypto.minus(feeCrypto).stripZeroPlainString(), - feeCrypto = feeCrypto.stripZeroPlainString().addPrecisionSign(), - totalCrypto = amountState.amountToSendCrypto.stripZeroPlainString(), - feeFiat = feeFiat, - willSentFiat = convertToFiatPrecision(amountState.amountToSendCrypto), - symbols = symbols, - ) - } else { - val totalCrypto = amountState.amountToSendCrypto.plus(feeCrypto) - return ReceiptCrypto( - amountCrypto = amountState.amountToSendCrypto.stripZeroPlainString(), - feeCrypto = feeCrypto.stripZeroPlainString().addPrecisionSign(), - totalCrypto = totalCrypto.stripZeroPlainString(), - feeFiat = feeFiat, - willSentFiat = convertToFiatPrecision(totalCrypto), - symbols = symbols, - ) - } - } - - private fun createTokenFiatType(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptTokenFiat { - val feeCoin = feeState.getCurrentFeeValue() - if (showBlank) { - val feeFiat = convertToFiatPrecision(feeCoin) - return ReceiptTokenFiat("0", feeFiat, "0", "0", "0", symbols) - } - - val tokensToSend = amountState.amountToSendCrypto - - return if (sendState.coinIsConvertible() && sendState.tokenIsConvertible()) { - val feeFiat = sendState.coinConverter!!.toFiatUnscaled(feeCoin) - val amountFiat = sendState.tokenConverter!!.toFiatUnscaled(tokensToSend) - val totalFiat = amountFiat.plus(feeFiat) - ReceiptTokenFiat( - amountFiat = amountFiat.scaleToFiat(true).stripZeroPlainString(), - feeFiat = feeFiat.scaleToFiat(true).stripZeroPlainString().addPrecisionSign(), - totalFiat = totalFiat.scaleToFiat(true).stripZeroPlainString(), - willSentToken = tokensToSend.stripZeroPlainString(), - willSentFeeCoin = feeCoin.stripZeroPlainString(), - symbols = symbols, - ) - } else { - ReceiptTokenFiat( - amountFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - feeFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - totalFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - willSentToken = tokensToSend.stripZeroPlainString(), - willSentFeeCoin = feeCoin.stripZeroPlainString(), - symbols = symbols, - ) - } - } - - private fun createTokenCryptoType(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptTokenCrypto { - val feeCoin = feeState.getCurrentFeeValue() - if (showBlank) { - return ReceiptTokenCrypto("0", feeCoin.stripZeroPlainString(), "0", symbols) - } - val tokensToSend = amountState.amountToSendCrypto - - return if (sendState.coinIsConvertible() && sendState.tokenIsConvertible()) { - val tokenFiat = sendState.tokenConverter!!.toFiatUnscaled(tokensToSend) - val feeFiat = sendState.coinConverter!!.toFiatUnscaled(feeCoin) - val totalFiat = tokenFiat.plus(feeFiat) - - ReceiptTokenCrypto( - amountToken = tokensToSend.stripZeroPlainString(), - feeCoin = feeCoin.stripZeroPlainString().addPrecisionSign(), - totalFiat = totalFiat.scaleToFiat(true).stripZeroPlainString(), - symbols = symbols, - ) - } else { - ReceiptTokenCrypto( - amountToken = tokensToSend.stripZeroPlainString(), - feeCoin = feeCoin.stripZeroPlainString().addPrecisionSign(), - totalFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - symbols = symbols, - ) - } - } - - private fun createCustomTokenCrypto(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptTokenCrypto { - val feeValue = feeState.getCurrentFeeValue() - if (showBlank) { - return ReceiptTokenCrypto("0", feeValue.stripZeroPlainString(), "0", symbols) - } - val tokensToSend = amountState.amountToSendCrypto - val amountType = amountState.typeOfAmount - val currencyConverter = when { - amountType is AmountType.Token && sendState.feeIsConvertible() -> sendState.tokenConverter - amountType is AmountType.Coin && sendState.feeIsConvertible() -> sendState.coinConverter - else -> null - } - - return if (currencyConverter != null) { - val currencyFiat = currencyConverter.toFiatUnscaled(tokensToSend) - val feeFiat = sendState.customFeeConverter!!.toFiatUnscaled(feeValue) - val totalFiat = currencyFiat.plus(feeFiat) - - ReceiptTokenCrypto( - amountToken = tokensToSend.stripZeroPlainString(), - feeCoin = feeValue.stripZeroPlainString().addPrecisionSign(), - totalFiat = totalFiat.scaleToFiat(true).stripZeroPlainString(), - symbols = symbols, - ) - } else { - ReceiptTokenCrypto( - amountToken = tokensToSend.stripZeroPlainString(), - feeCoin = feeValue.stripZeroPlainString().addPrecisionSign(), - totalFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - symbols = symbols, - ) - } - } - - private fun createCustomTokenFiat(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptTokenFiat { - val feeCoin = feeState.getCurrentFeeValue() - if (showBlank) { - val feeFiat = convertToFiatPrecision(feeCoin) - return ReceiptTokenFiat("0", feeFiat, "0", "0", "0", symbols) - } - - val tokensToSend = amountState.amountToSendCrypto - - val amountType = amountState.typeOfAmount - val currencyConverter = when { - amountType is AmountType.Token && sendState.feeIsConvertible() -> sendState.tokenConverter - amountType is AmountType.Coin && sendState.feeIsConvertible() -> sendState.coinConverter - else -> null - } - - return if (currencyConverter != null) { - val feeFiat = sendState.customFeeConverter!!.toFiatUnscaled(feeCoin) - val amountFiat = currencyConverter.toFiatUnscaled(tokensToSend) - val totalFiat = amountFiat.plus(feeFiat) - ReceiptTokenFiat( - amountFiat = amountFiat.scaleToFiat(true).stripZeroPlainString(), - feeFiat = feeFiat.scaleToFiat(true).stripZeroPlainString().addPrecisionSign(), - totalFiat = totalFiat.scaleToFiat(true).stripZeroPlainString(), - willSentToken = tokensToSend.scaleToFiat(true).stripZeroPlainString(), - willSentFeeCoin = feeCoin.stripZeroPlainString(), - symbols = symbols, - ) - } else { - ReceiptTokenFiat( - amountFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - feeFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - totalFiat = BigDecimalFormatter.EMPTY_BALANCE_SIGN, - willSentToken = tokensToSend.stripZeroPlainString(), - willSentFeeCoin = feeCoin.stripZeroPlainString(), - symbols = symbols, - ) - } - } - - private fun createSameCurrencyCrypto(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptCrypto { - val feeCrypto = feeState.getCurrentFeeValue() - val feeFiat = convertToFiatPrecision(feeCrypto) - if (showBlank) { - return ReceiptCrypto("0", feeCrypto.stripZeroPlainString(), "0", "0", "0", symbols) - } - val isToken = amountState.typeOfAmount is AmountType.Token - - if (feeState.feeIsIncluded) { - return ReceiptCrypto( - amountCrypto = amountState.amountToSendCrypto.minus(feeCrypto).stripZeroPlainString(), - feeCrypto = feeCrypto.stripZeroPlainString().addPrecisionSign(), - totalCrypto = amountState.amountToSendCrypto.stripZeroPlainString(), - feeFiat = feeFiat, - willSentFiat = convertToFiatPrecision(value = amountState.amountToSendCrypto, isToken = isToken), - symbols = symbols, - ) - } else { - val totalCrypto = amountState.amountToSendCrypto.plus(feeCrypto) - return ReceiptCrypto( - amountCrypto = amountState.amountToSendCrypto.stripZeroPlainString(), - feeCrypto = feeCrypto.stripZeroPlainString().addPrecisionSign(), - totalCrypto = totalCrypto.stripZeroPlainString(), - feeFiat = feeFiat, - willSentFiat = convertToFiatPrecision(value = totalCrypto, isToken = isToken), - symbols = symbols, - ) - } - } - - private fun createSameCurrencyFiat(symbols: ReceiptSymbols, showBlank: Boolean): ReceiptFiat { - val feeCrypto = feeState.getCurrentFeeValue() - val isToken = amountState.typeOfAmount is AmountType.Token - val feeFiat = convertToFiatPrecision(feeCrypto, isToken = isToken) - - if (showBlank) { - return ReceiptFiat("0", feeFiat, "0", "0", symbols) - } - - return if (feeState.feeIsIncluded) { - val amountFiat = convertToFiatPrecision(amountState.amountToSendCrypto.minus(feeCrypto), isToken = isToken) - val totalFiat = convertToFiatPrecision(amountState.amountToSendCrypto, isToken = isToken) - ReceiptFiat( - amountFiat = amountFiat, - feeFiat = feeFiat, - totalFiat = totalFiat, - willSentCrypto = amountState.amountToSendCrypto.scaleToFiat(true).stripZeroPlainString(), - symbols = symbols, - ) - } else { - val totalAmountCrypto = amountState.amountToSendCrypto.plus(feeCrypto) - val amountFiat = convertToFiatPrecision(amountState.amountToSendCrypto, isToken = isToken) - val totalFiat = convertToFiatPrecision(totalAmountCrypto, isToken = isToken) - ReceiptFiat( - amountFiat = amountFiat, - feeFiat = feeFiat, - totalFiat = totalFiat, - willSentCrypto = totalAmountCrypto.scaleToFiat().stripZeroPlainString(), - symbols = symbols, - ) - } - } - - private fun determineSymbols( - wallet: Wallet, - amountType: AmountType, - feePaidCurrency: FeePaidCurrency, - ): ReceiptSymbols { - return ReceiptSymbols( - fiat = store.state.globalState.appCurrency.code, - crypto = wallet.blockchain.currency, - token = when (amountType) { - is AmountType.Token -> amountType.token.symbol - else -> null - }, - fee = when (feePaidCurrency) { - FeePaidCurrency.Coin -> wallet.blockchain.currency - FeePaidCurrency.SameCurrency -> store.state.sendState.currency?.symbol - is FeePaidCurrency.Token -> feePaidCurrency.token.symbol - is FeePaidCurrency.FeeResource -> feePaidCurrency.currency - }, - ) - } - - private fun determineLayoutType( - mainCurrencyType: MainCurrencyType, - amountType: AmountType, - feePaidCurrency: FeePaidCurrency, - ): ReceiptLayoutType { - return when (mainCurrencyType) { - MainCurrencyType.FIAT -> determineFiatLayoutType(feePaidCurrency, amountType) - MainCurrencyType.CRYPTO -> determineCryptoLayoutType(feePaidCurrency, amountType) - } - } - - private fun determineFiatLayoutType(feePaidCurrency: FeePaidCurrency, amountType: AmountType): ReceiptLayoutType { - return when (feePaidCurrency) { - is FeePaidCurrency.Token -> { - val amountToken = (amountType as? AmountType.Token)?.token - val sameToken = - feePaidCurrency.token.contractAddress.equals(amountToken?.contractAddress, ignoreCase = true) - if (sameToken) ReceiptLayoutType.SAME_CURRENCY_FIAT else ReceiptLayoutType.FEE_IN_CUSTOM_TOKEN_FIAT - } - FeePaidCurrency.Coin -> when (amountType) { - AmountType.Coin -> ReceiptLayoutType.FIAT - is AmountType.Token -> ReceiptLayoutType.TOKEN_FIAT - is AmountType.FeeResource, - AmountType.Reserve, - -> ReceiptLayoutType.UNKNOWN - } - FeePaidCurrency.SameCurrency -> ReceiptLayoutType.SAME_CURRENCY_FIAT - is FeePaidCurrency.FeeResource -> ReceiptLayoutType.SAME_CURRENCY - } - } - - private fun determineCryptoLayoutType(feePaidCurrency: FeePaidCurrency, amountType: AmountType): ReceiptLayoutType { - return when (feePaidCurrency) { - is FeePaidCurrency.Token -> { - val amountToken = (amountType as? AmountType.Token)?.token - val sameToken = - feePaidCurrency.token.contractAddress.equals(amountToken?.contractAddress, ignoreCase = true) - if (sameToken) ReceiptLayoutType.SAME_CURRENCY else ReceiptLayoutType.FEE_IN_CUSTOM_TOKEN - } - FeePaidCurrency.SameCurrency -> ReceiptLayoutType.SAME_CURRENCY - FeePaidCurrency.Coin -> when (amountType) { - AmountType.Coin -> ReceiptLayoutType.CRYPTO - is AmountType.Token -> ReceiptLayoutType.TOKEN_CRYPTO - is AmountType.FeeResource, - AmountType.Reserve, - -> ReceiptLayoutType.UNKNOWN - } - is FeePaidCurrency.FeeResource -> ReceiptLayoutType.UNKNOWN - } - } - - private fun convertToFiatPrecision(value: BigDecimal, isToken: Boolean = false): String { - return when { - !isToken && sendState.coinIsConvertible() -> { - sendState.coinConverter!!.toFiatWithPrecision(value).stripZeroPlainString() - } - isToken && sendState.tokenIsConvertible() -> { - sendState.tokenConverter!!.toFiatWithPrecision(value).stripZeroPlainString() - } - else -> { - BigDecimalFormatter.EMPTY_BALANCE_SIGN - } - } - } - - private fun String.addPrecisionSign(): String { - val result = if (feeState.feeIsApproximate) "$LOWER_SIGN $this" else this - return result.trim() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/SendScreenReducer.kt b/app/src/main/java/com/tangem/tap/features/send/redux/reducers/SendScreenReducer.kt deleted file mode 100644 index 9ac9254fec..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/SendScreenReducer.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.tangem.tap.features.send.redux.reducers - -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.FeePaidCurrency -import com.tangem.tap.common.CurrencyConverter -import com.tangem.tap.common.entities.IndeterminateProgressButton -import com.tangem.tap.features.send.redux.* -import com.tangem.tap.features.send.redux.states.ExternalTransactionData -import com.tangem.tap.features.send.redux.states.IdStateHolder -import com.tangem.tap.features.send.redux.states.SendState -import org.rekotlin.Action -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -interface SendInternalReducer { - fun handle(action: SendScreenAction, sendState: SendState): SendState -} - -object SendScreenReducer { - fun reduce(incomingAction: Action, sendState: SendState): SendState { - if (incomingAction is ReleaseSendState) return SendState() - val action = incomingAction as? SendScreenAction ?: return sendState - - val reducer: SendInternalReducer = when (action) { - is PrepareSendScreen -> PrepareSendScreenStatesReducer() - is AddressActionUi, is AddressVerifyAction -> AddressReducer() - is TransactionExtrasAction -> TransactionExtrasReducer() - is AmountActionUi, is AmountAction -> AmountReducer() - is FeeActionUi, is FeeAction -> FeeReducer() - is ReceiptAction -> ReceiptReducer() - is SendAction -> SendReducer() - else -> EmptyReducer() - } - - return reducer.handle(action, sendState) - } -} - -private class SendReducer : SendInternalReducer { - override fun handle(action: SendScreenAction, sendState: SendState): SendState { - val result = when (action) { - is SendAction.ChangeSendButtonState -> { - sendState.copy(sendButtonState = IndeterminateProgressButton(action.state)) - } - is SendAction.Dialog.TezosWarningDialog -> sendState.copy(dialog = action) - is SendAction.Dialog.KaspaWarningDialog -> sendState.copy(dialog = action) - is SendAction.Dialog.ChiaWarningDialog -> sendState.copy(dialog = action) - is SendAction.Dialog.SendTransactionFails.CardSdkError -> sendState.copy(dialog = action) - is SendAction.Dialog.SendTransactionFails.BlockchainSdkError -> sendState.copy(dialog = action) - is SendAction.Dialog.RequestFeeError -> sendState.copy(dialog = action) - is SendAction.Dialog.Hide -> sendState.copy(dialog = null) - is SendAction.Warnings.Set -> sendState.copy(sendWarningsList = action.warningList) - is SendAction.SendSpecificTransaction -> handleSendSpecificTransactionAction(action, sendState) - is SendAction.SendSuccess -> sendState.copy(isSuccessSend = true) - else -> return sendState - } - - return updateLastState(result, result) - } - - private fun handleSendSpecificTransactionAction( - action: SendAction.SendSpecificTransaction, - state: SendState, - ): SendState { - return state.copy( - externalTransactionData = - ExternalTransactionData(action.sendAmount, action.destinationAddress, action.transactionId), - feeState = state.feeState.copy( - includeFeeSwitcherIsEnabled = false, - ), - amountState = state.amountState.copy( - inputIsEnabled = false, - ), - addressState = state.addressState.copy( - inputIsEnabled = false, - ), - ) - } -} - -private class EmptyReducer : SendInternalReducer { - override fun handle(action: SendScreenAction, sendState: SendState): SendState = sendState -} - -private class PrepareSendScreenStatesReducer : SendInternalReducer { - override fun handle(action: SendScreenAction, sendState: SendState): SendState { - val prepareAction = action as PrepareSendScreen - val walletManager = action.walletManager - val amountToExtract = prepareAction.tokenAmount ?: prepareAction.coinAmount!! - val decimals = amountToExtract.decimals - val canIncludeFee = canIncludeFee( - typeOfAmount = amountToExtract.type, - feePaidCurrency = action.feePaidCurrency, - ) - - return sendState.copy( - walletManager = walletManager, - coinConverter = action.coinRate?.let { CurrencyConverter(it, decimals) }, - tokenConverter = action.tokenRate?.let { CurrencyConverter(it, decimals) }, - customFeeConverter = action.feeCurrencyRate?.let { CurrencyConverter(it, action.feeCurrencyDecimals) }, - amountState = sendState.amountState.copy( - amountToExtract = amountToExtract, - typeOfAmount = amountToExtract.type, - balanceCrypto = amountToExtract.value ?: BigDecimal.ZERO, - ), - feeState = sendState.feeState.copy(includeFeeSwitcherIsEnabled = canIncludeFee), - canIncludeFee = canIncludeFee, - currency = action.currency, - ) - } - - private fun canIncludeFee(typeOfAmount: AmountType, feePaidCurrency: FeePaidCurrency): Boolean { - return when (feePaidCurrency) { - FeePaidCurrency.Coin -> typeOfAmount == AmountType.Coin - FeePaidCurrency.SameCurrency -> true - is FeePaidCurrency.Token -> { - val sendToken = (typeOfAmount as? AmountType.Token)?.token ?: return false - - sendToken.contractAddress.equals(feePaidCurrency.token.contractAddress, ignoreCase = true) && - sendToken.name.equals(feePaidCurrency.token.name, ignoreCase = true) && - sendToken.symbol.equals(feePaidCurrency.token.symbol, ignoreCase = true) - } - is FeePaidCurrency.FeeResource -> false - } - } -} - -internal fun updateLastState(sendState: SendState, lastChangedState: IdStateHolder): SendState { - sendState.lastChangedStates.add(lastChangedState.stateId) - return sendState -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/TransactionExtrasReducer.kt b/app/src/main/java/com/tangem/tap/features/send/redux/reducers/TransactionExtrasReducer.kt deleted file mode 100644 index c781547049..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/reducers/TransactionExtrasReducer.kt +++ /dev/null @@ -1,201 +0,0 @@ -package com.tangem.tap.features.send.redux.reducers - -import com.tangem.blockchain.blockchains.stellar.StellarMemo -import com.tangem.blockchain.common.Blockchain -import com.tangem.tap.features.send.redux.SendScreenAction -import com.tangem.tap.features.send.redux.TransactionExtrasAction.* -import com.tangem.tap.features.send.redux.states.* - -/** -[REDACTED_AUTHOR] - */ -class TransactionExtrasReducer : SendInternalReducer { - override fun handle(action: SendScreenAction, sendState: SendState): SendState { - return when (action) { - is Prepare -> handleInitialization(action, sendState) - Release -> handleRelease(sendState) - is XlmMemo -> handleXlmMemo(action, sendState, sendState.transactionExtrasState) - is BinanceMemo -> handleBinanceMemo(action, sendState, sendState.transactionExtrasState) - is XrpDestinationTag -> handleXrpTag(action, sendState, sendState.transactionExtrasState) - is TonMemo -> handleTonMemo(action, sendState, sendState.transactionExtrasState) - is CosmosMemo -> handleCosmosMemo(action, sendState, sendState.transactionExtrasState) - is HederaMemo -> handleHederaMemo(action, sendState, sendState.transactionExtrasState) - is AlgorandMemo -> handleAlgorandMemo(action, sendState, sendState.transactionExtrasState) - else -> sendState - } - } - - private fun handleInitialization(action: Prepare, sendState: SendState): SendState { - val emptyResult = TransactionExtrasState() - val result = when (action.blockchain) { - Blockchain.XRP -> { - val address = action.walletAddress.substringAfter(":") - // 'r' - without tag, 'x' - with tag - if (address.startsWith("r", true)) { - val tag = action.xrpTag?.toLongOrNull() - if (tag == null) { - TransactionExtrasState(xrpDestinationTag = XrpDestinationTagState()) - } else { - TransactionExtrasState( - xrpDestinationTag = XrpDestinationTagState( - viewFieldValue = InputViewValue("$tag", false), - tag = tag, - ), - ) - } - } else { - emptyResult - } - } - Blockchain.Stellar -> TransactionExtrasState(xlmMemo = XlmMemoState()) - Blockchain.Binance -> TransactionExtrasState(binanceMemo = BinanceMemoState()) - Blockchain.TON, Blockchain.TONTestnet -> TransactionExtrasState(tonMemoState = TonMemoState()) - Blockchain.Cosmos, - Blockchain.TerraV1, - Blockchain.TerraV2, - -> TransactionExtrasState(cosmosMemoState = CosmosMemoState()) - Blockchain.Hedera, Blockchain.HederaTestnet -> TransactionExtrasState(hederaMemoState = HederaMemoState()) - Blockchain.Algorand, Blockchain.AlgorandTestnet -> TransactionExtrasState( - algorandMemoState = AlgorandMemoState(), - ) - else -> emptyResult - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleRelease(sendState: SendState): SendState { - val result = TransactionExtrasState() - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleXlmMemo(action: XlmMemo, sendState: SendState, infoState: TransactionExtrasState): SendState { - fun clearMemo(memo: XlmMemoState): XlmMemoState = memo.copy(text = null, id = null, error = null) - - val result = when (action) { - is XlmMemo.HandleUserInput -> { - val inputViewValue = InputViewValue(action.data, true) - var memo = infoState.xlmMemo?.copy(viewFieldValue = inputViewValue) - ?: XlmMemoState(inputViewValue) - memo = clearMemo(memo) - memo = when (memo.selectedMemoType) { - XlmMemoType.TEXT -> { - if (XlmMemoState.isAssignableValue(action.data)) { - memo.copy(text = StellarMemo.Text(action.data)) - } else { - memo.copy(error = TransactionExtraError.INVALID_XLM_MEMO) - } - } - XlmMemoType.ID -> { - if (XlmMemoState.isAssignableValue(action.data)) { - memo.copy(id = StellarMemo.Id(action.data.toBigInteger())) - } else { - memo.copy(error = TransactionExtraError.INVALID_XLM_MEMO) - } - } - } - infoState.copy(xlmMemo = memo) - } - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleBinanceMemo( - action: BinanceMemo, - sendState: SendState, - infoState: TransactionExtrasState, - ): SendState { - val result = when (action) { - is BinanceMemo.HandleUserInput -> { - val tag = action.data.toBigIntegerOrNull() - if (tag != null) { - val input = InputViewValue(action.data, true) - val tagState = BinanceMemoState(input, tag) - infoState.copy(binanceMemo = tagState) - } else { - infoState - } - } - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleXrpTag( - action: XrpDestinationTag, - sendState: SendState, - infoState: TransactionExtrasState, - ): SendState { - val result = when (action) { - is XrpDestinationTag.HandleUserInput -> { - val tag = action.data.toLongOrNull() - if (tag != null) { - val input = InputViewValue(action.data, true) - val tagState = if (tag <= XrpDestinationTagState.MAX_NUMBER) { - XrpDestinationTagState(input, tag) - } else { - XrpDestinationTagState(input, error = TransactionExtraError.INVALID_DESTINATION_TAG) - } - infoState.copy(xrpDestinationTag = tagState) - } else { - infoState - } - } - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleTonMemo(action: TonMemo, sendState: SendState, infoState: TransactionExtrasState): SendState { - val result = when (action) { - is TonMemo.HandleUserInput -> { - val memo = action.data - val input = InputViewValue(memo, true) - infoState.copy(tonMemoState = TonMemoState(input, memo)) - } - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleCosmosMemo( - action: CosmosMemo, - sendState: SendState, - infoState: TransactionExtrasState, - ): SendState { - val result = when (action) { - is CosmosMemo.HandleUserInput -> { - val memo = action.data - val input = InputViewValue(memo, true) - infoState.copy(cosmosMemoState = CosmosMemoState(input, memo)) - } - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleHederaMemo( - action: HederaMemo, - sendState: SendState, - infoState: TransactionExtrasState, - ): SendState { - val result = when (action) { - is HederaMemo.HandleUserInput -> { - val memo = action.data - val input = InputViewValue(memo, true) - infoState.copy(hederaMemoState = HederaMemoState(input, memo)) - } - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } - - private fun handleAlgorandMemo( - action: AlgorandMemo, - sendState: SendState, - infoState: TransactionExtrasState, - ): SendState { - val result = when (action) { - is AlgorandMemo.HandleUserInput -> { - val memo = action.data - val input = InputViewValue(memo, true) - infoState.copy(algorandMemoState = AlgorandMemoState(input, memo)) - } - } - return updateLastState(sendState.copy(transactionExtrasState = result), result) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/states/AddressState.kt b/app/src/main/java/com/tangem/tap/features/send/redux/states/AddressState.kt deleted file mode 100644 index 3310a772ce..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/states/AddressState.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.tangem.tap.features.send.redux.states - -import androidx.core.text.isDigitsOnly -import com.tangem.blockchain.blockchains.stellar.StellarMemo -import com.tangem.tap.features.send.redux.AddressVerifyAction -import java.math.BigInteger - -data class AddressState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val normalFieldValue: String? = null, - val truncatedFieldValue: String? = null, - val destinationWalletAddress: String? = null, - val error: AddressVerifyAction.Error? = null, - val truncateHandler: ((String) -> String)? = null, - val pasteIsEnabled: Boolean = false, - val inputIsEnabled: Boolean = true, -) : SendScreenState { - - override val stateId: StateId = StateId.ADDRESS_PAY_ID - - fun truncate(value: String): String = truncateHandler?.invoke(value) ?: value - - fun isReady(): Boolean = error == null && destinationWalletAddress?.isNotEmpty() ?: false -} - -@Deprecated("Legacy") -data class TransactionExtrasState( - val xlmMemo: XlmMemoState? = null, - val binanceMemo: BinanceMemoState? = null, - val xrpDestinationTag: XrpDestinationTagState? = null, - val tonMemoState: TonMemoState? = null, - val cosmosMemoState: CosmosMemoState? = null, - val hederaMemoState: HederaMemoState? = null, - val algorandMemoState: AlgorandMemoState? = null, -) : IdStateHolder { - override val stateId: StateId = StateId.TRANSACTION_EXTRAS - - fun isNull(): Boolean { - return xlmMemo == null && binanceMemo == null && xrpDestinationTag == null && tonMemoState == null && - cosmosMemoState == null && hederaMemoState == null && algorandMemoState == null - } - - fun isEmpty(): Boolean { - val isXlmEmpty = xlmMemo?.viewFieldValue?.value?.isEmpty() ?: false - val isBinanceEmpty = binanceMemo?.viewFieldValue?.value?.isEmpty() ?: false - val isXrpEmpty = xrpDestinationTag?.viewFieldValue?.value?.isEmpty() ?: false - val isTonEmpty = tonMemoState?.viewFieldValue?.value?.isEmpty() ?: false - val isCosmosEmpty = cosmosMemoState?.viewFieldValue?.value?.isEmpty() ?: false - val isHederaEmpty = hederaMemoState?.viewFieldValue?.value?.isEmpty() ?: false - val isAlgorandEmpty = algorandMemoState?.viewFieldValue?.value?.isEmpty() ?: false - - return isXlmEmpty || - isBinanceEmpty || - isXrpEmpty || - isTonEmpty || - isCosmosEmpty || - isHederaEmpty || - isAlgorandEmpty - } -} - -enum class XlmMemoType { - TEXT, ID -} - -data class XlmMemoState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val text: StellarMemo.Text? = null, - val id: StellarMemo.Id? = null, - val error: TransactionExtraError? = null, -) { - val memo: StellarMemo? - get() = when (selectedMemoType) { - XlmMemoType.TEXT -> text - XlmMemoType.ID -> id - } - - val selectedMemoType: XlmMemoType - get() = determineMemoType(viewFieldValue.value) - - companion object { - fun determineMemoType(value: String): XlmMemoType = when { - value.isNotEmpty() && value.isDigitsOnly() -> XlmMemoType.ID - else -> XlmMemoType.TEXT - } - - @Suppress("MagicNumber") - fun isAssignableValue(value: String): Boolean = when (determineMemoType(value)) { - XlmMemoType.TEXT -> { - // from org.stellar.sdk.MemoText - value.toByteArray().size <= 28 - } - XlmMemoType.ID -> { - try { - // from com.tangem.blockchain.blockchains.stellar.StellarMemo.toStellarSdkMemo - value.toBigInteger() in BigInteger.ZERO..Long.MAX_VALUE.toBigInteger() * 2.toBigInteger() - } catch (ex: NumberFormatException) { - false - } - } - } - } -} - -data class BinanceMemoState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val memo: BigInteger? = null, - val error: TransactionExtraError? = null, -) - -// tag must contains only digits -data class XrpDestinationTagState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val tag: Long? = null, - val error: TransactionExtraError? = null, -) { - companion object { - const val MAX_NUMBER: Long = 4294967295 - } -} - -data class TonMemoState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val memo: String? = null, - val error: TransactionExtraError? = null, -) - -data class CosmosMemoState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val memo: String? = null, - val error: TransactionExtraError? = null, -) - -data class HederaMemoState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val memo: String? = null, - val error: TransactionExtraError? = null, -) - -data class AlgorandMemoState( - val viewFieldValue: InputViewValue = InputViewValue(""), - val memo: String? = null, - val error: TransactionExtraError? = null, -) - -enum class TransactionExtraError { - INVALID_DESTINATION_TAG, - INVALID_XLM_MEMO, - INVALID_BINANCE_MEMO, -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/states/FeeState.kt b/app/src/main/java/com/tangem/tap/features/send/redux/states/FeeState.kt deleted file mode 100644 index 27ae5f8a33..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/states/FeeState.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.tap.features.send.redux.states - -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.tap.common.entities.ProgressState -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ -enum class FeeType { - SINGLE, LOW, NORMAL, PRIORITY; -} - -fun FeeType.convertToAnalyticsFeeType(): AnalyticsParam.FeeType { - return when (this) { - FeeType.SINGLE -> AnalyticsParam.FeeType.Fixed - FeeType.LOW -> AnalyticsParam.FeeType.Min - FeeType.NORMAL -> AnalyticsParam.FeeType.Normal - FeeType.PRIORITY -> AnalyticsParam.FeeType.Max - } -} - -data class FeeState( - val selectedFeeType: FeeType = FeeType.NORMAL, - val fees: TransactionFee? = null, - val currentFee: Fee? = null, - val feeIsIncluded: Boolean = false, - val feeIsApproximate: Boolean = false, - val mainLayoutIsVisible: Boolean = false, - val controlsLayoutIsVisible: Boolean = false, - val feeChipGroupIsVisible: Boolean = true, - val includeFeeSwitcherIsEnabled: Boolean = true, - val progressState: ProgressState = ProgressState.Done, -) : SendScreenState { - - override val stateId: StateId = StateId.FEE - - fun isReady(): Boolean = currentFee != null - - fun getCurrentFeeValue(): BigDecimal = currentFee?.amount?.value ?: BigDecimal.ZERO -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/states/ReceiptState.kt b/app/src/main/java/com/tangem/tap/features/send/redux/states/ReceiptState.kt deleted file mode 100644 index f23d760f1a..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/states/ReceiptState.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.tangem.tap.features.send.redux.states - -// Shows only one type of the layout -enum class ReceiptLayoutType { - UNKNOWN, - FIAT, - CRYPTO, - TOKEN_FIAT, - TOKEN_CRYPTO, - FEE_IN_CUSTOM_TOKEN, - FEE_IN_CUSTOM_TOKEN_FIAT, - SAME_CURRENCY, - SAME_CURRENCY_FIAT, -} - -data class ReceiptState( - val visibleTypeOfReceipt: ReceiptLayoutType? = null, - val fiat: ReceiptFiat? = null, - val crypto: ReceiptCrypto? = null, - val tokenFiat: ReceiptTokenFiat? = null, - val tokenCrypto: ReceiptTokenCrypto? = null, - val customTokenFiat: ReceiptTokenFiat? = null, - val customTokenCrypto: ReceiptTokenCrypto? = null, - val sameCurrencyFiat: ReceiptFiat? = null, - val sameCurrencyCrypto: ReceiptCrypto? = null, - val mainCurrency: MainCurrency? = null, -) : SendScreenState { - override val stateId: StateId = StateId.RECEIPT -} - -data class ReceiptSymbols( - val fiat: String, - val crypto: String, - val token: String? = null, - val fee: String? = null, -) - -data class ReceiptFiat( - val amountFiat: String, - val feeFiat: String, - val totalFiat: String, - val willSentCrypto: String, - val symbols: ReceiptSymbols, -) - -data class ReceiptCrypto( - val amountCrypto: String, - val feeCrypto: String, - val totalCrypto: String, - val feeFiat: String, - val willSentFiat: String, - val symbols: ReceiptSymbols, -) - -data class ReceiptTokenCrypto( - val amountToken: String, - val feeCoin: String, - val totalFiat: String, - val symbols: ReceiptSymbols, -) - -data class ReceiptTokenFiat( - val amountFiat: String, - val feeFiat: String, - val totalFiat: String, - val willSentToken: String, - val willSentFeeCoin: String, - val symbols: ReceiptSymbols, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/states/SendState.kt b/app/src/main/java/com/tangem/tap/features/send/redux/states/SendState.kt deleted file mode 100644 index 115ab92034..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/redux/states/SendState.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.tangem.tap.features.send.redux.states - -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.AmountType -import com.tangem.blockchain.common.WalletManager -import com.tangem.common.extensions.isZero -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.redux.StateDialog -import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.tap.common.CurrencyConverter -import com.tangem.tap.common.entities.IndeterminateProgressButton -import com.tangem.tap.common.text.DecimalDigitsInputFilter -import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.configurable.warningMessage.WarningMessage -import com.tangem.tap.store -import org.rekotlin.StateType -import java.math.BigDecimal - -/** -[REDACTED_AUTHOR] - */ - -interface IdStateHolder { - val stateId: StateId -} - -enum class StateId { - SEND_SCREEN, ADDRESS_PAY_ID, TRANSACTION_EXTRAS, AMOUNT, FEE, RECEIPT -} - -interface SendScreenState : StateType, IdStateHolder - -data class SendState( - val walletManager: WalletManager? = null, - val currency: CryptoCurrency? = null, - val coinConverter: CurrencyConverter? = null, - val tokenConverter: CurrencyConverter? = null, - val customFeeConverter: CurrencyConverter? = null, - val lastChangedStates: LinkedHashSet = linkedSetOf(), - val addressState: AddressState = AddressState(), - val transactionExtrasState: TransactionExtrasState = TransactionExtrasState(), - val amountState: AmountState = AmountState(), - val feeState: FeeState = FeeState(), - val receiptState: ReceiptState = ReceiptState(), - val sendWarningsList: List = listOf(), - val sendButtonState: IndeterminateProgressButton = IndeterminateProgressButton(ButtonState.DISABLED), - val dialog: StateDialog? = null, - val externalTransactionData: ExternalTransactionData? = null, - val isSuccessSend: Boolean = false, - val canIncludeFee: Boolean = false, -) : SendScreenState { - - override val stateId: StateId = StateId.SEND_SCREEN - - fun getDecimals(type: MainCurrencyType): Int = when (type) { - MainCurrencyType.FIAT -> 2 - MainCurrencyType.CRYPTO -> amountState.amountToExtract?.decimals ?: 0 - } - - private fun convertFiatToCoin(value: BigDecimal): BigDecimal { - return if (!this.coinIsConvertible()) value else coinConverter!!.toCrypto(value) - } - - private fun convertFiatToToken(value: BigDecimal): BigDecimal { - return if (!this.tokenIsConvertible()) value else tokenConverter!!.toCrypto(value) - } - - private fun convertCoinToFiat(value: BigDecimal, scaleWithPrecision: Boolean = false): BigDecimal { - if (!this.coinIsConvertible()) return value - - val converter = coinConverter!! - return if (!scaleWithPrecision) converter.toFiat(value) else converter.toFiatWithPrecision(value) - } - - private fun convertTokenToFiat(value: BigDecimal, scaleWithPrecision: Boolean = false): BigDecimal { - if (!this.tokenIsConvertible()) return value - - val converter = tokenConverter!! - return if (!scaleWithPrecision) converter.toFiat(value) else converter.toFiatWithPrecision(value) - } - - fun convertFiatToExtractCrypto(fiatValue: BigDecimal): BigDecimal = when (amountState.typeOfAmount) { - AmountType.Coin -> convertFiatToCoin(fiatValue) - is AmountType.Token -> convertFiatToToken(fiatValue) - is AmountType.FeeResource, - AmountType.Reserve, - -> fiatValue - } - - fun convertExtractCryptoToFiat(cryptoValue: BigDecimal, scaleWithPrecision: Boolean = false): BigDecimal { - return when (amountState.typeOfAmount) { - AmountType.Coin -> convertCoinToFiat(cryptoValue, scaleWithPrecision) - is AmountType.Token -> convertTokenToFiat(cryptoValue, scaleWithPrecision) - is AmountType.FeeResource, - AmountType.Reserve, - -> cryptoValue - } - } - - fun getButtonState(): ButtonState = if (isReadyToSend()) ButtonState.ENABLED else ButtonState.DISABLED - - fun getTotalAmountToSend(value: BigDecimal = amountState.amountToSendCrypto): BigDecimal { - val needToExtractFee = amountState.canIncludeFee() && feeState.feeIsIncluded - return if (needToExtractFee) value.minus(feeState.getCurrentFeeValue()) else value - } - - fun coinIsConvertible(): Boolean = coinConverter != null - fun tokenIsConvertible(): Boolean = tokenConverter != null - fun feeIsConvertible(): Boolean = customFeeConverter != null - - fun mainCurrencyCanBeSwitched(): Boolean { - return when (amountState.typeOfAmount) { - AmountType.Coin -> coinIsConvertible() - is AmountType.Token -> tokenIsConvertible() - is AmountType.FeeResource, - AmountType.Reserve, - -> false - } - } - - companion object { - private fun addressIsReady(): Boolean = store.state.sendState.addressState.isReady() - - private fun amountIsReady(): Boolean = store.state.sendState.amountState.isReady() - - fun isReadyToRequestFee(): Boolean = addressIsReady() && amountIsReady() - - fun isReadyToSend(): Boolean = addressIsReady() && amountIsReady() && - store.state.sendState.feeState.isReady() - } -} - -enum class ButtonState { - ENABLED, DISABLED, PROGRESS -} - -data class AmountState( - val amountToExtract: Amount? = null, - val typeOfAmount: AmountType = AmountType.Coin, - val viewAmountValue: InputViewValue = InputViewValue(BigDecimal.ZERO.toPlainString()), - val viewBalanceValue: String = BigDecimal.ZERO.toPlainString(), - val mainCurrency: MainCurrency = MainCurrency(MainCurrencyType.FIAT, AppCurrency.Default.code), - val amountToSendCrypto: BigDecimal = BigDecimal.ZERO, - val balanceCrypto: BigDecimal = BigDecimal.ZERO, - val hideBalance: Boolean = false, - val cursorAtTheSamePosition: Boolean = true, - val maxLengthOfAmount: Int = 2, - val decimalSeparator: String = ".", - val error: TapError? = null, - val inputIsEnabled: Boolean = true, -) : SendScreenState { - - override val stateId: StateId = StateId.AMOUNT - - fun isReady(): Boolean = error == null && !amountToSendCrypto.isZero() - - fun canIncludeFee(): Boolean = store.state.sendState.canIncludeFee - - fun createMainCurrency(type: MainCurrencyType, canSwitched: Boolean): MainCurrency { - return if (!canSwitched) { - MainCurrency(type, amountToExtract?.currencySymbol ?: "NONE", false) - } else { - when (type) { - MainCurrencyType.FIAT -> MainCurrency(type, store.state.globalState.appCurrency.code) - MainCurrencyType.CRYPTO -> MainCurrency(type, amountToExtract?.currencySymbol ?: "NONE") - } - } - } - - fun toBigDecimalSeparator(value: String): String { - return value.replace(",", ".") - } - - fun restoreDecimalSeparator(value: String): String { - return DecimalDigitsInputFilter.setDecimalSeparator(value, decimalSeparator) - } -} - -data class InputViewValue(val value: String, val isFromUserInput: Boolean = false) -enum class MainCurrencyType { - FIAT, CRYPTO -} - -data class MainCurrency( - val type: MainCurrencyType, - val currencySymbol: String, - val isEnabled: Boolean = true, -) - -data class ExternalTransactionData( - val amount: String, - val destinationAddress: String, - val transactionId: String, - val canAmountBeModified: Boolean = false, - val canDestinationAddressBeModified: Boolean = false, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/EditTextCustomPaste.kt b/app/src/main/java/com/tangem/tap/features/send/ui/EditTextCustomPaste.kt deleted file mode 100644 index d93147b24b..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/EditTextCustomPaste.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tangem.tap.features.send.ui - -import android.R -import android.content.Context -import android.util.AttributeSet -import com.google.android.material.textfield.TextInputEditText - -class EditTextCustomPaste : TextInputEditText { - - private var onSystemPasteButtonClickListener: (() -> Unit)? = null - - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - - fun setOnSystemPasteButtonClickListener(callback: () -> Unit) { - onSystemPasteButtonClickListener = callback - } - - override fun onTextContextMenuItem(id: Int): Boolean { - val isConsumed = super.onTextContextMenuItem(id) - - if (id == R.id.paste) onSystemPasteButtonClickListener?.invoke() - - return isConsumed - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/SendFragment.kt b/app/src/main/java/com/tangem/tap/features/send/ui/SendFragment.kt deleted file mode 100644 index 34650ad800..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/SendFragment.kt +++ /dev/null @@ -1,407 +0,0 @@ -@file:Suppress("MagicNumber") - -package com.tangem.tap.features.send.ui - -import android.content.Context -import android.os.Bundle -import android.text.method.DigitsKeyListener -import android.view.View -import android.view.inputmethod.EditorInfo -import android.widget.EditText -import androidx.core.view.postDelayed -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import by.kirich1409.viewbindingdelegate.viewBinding -import com.google.android.material.textfield.TextInputEditText -import com.tangem.Message -import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.Analytics -import com.tangem.domain.appcurrency.model.AppCurrency -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.tokens.legacy.TradeCryptoAction -import com.tangem.sdk.extensions.hideSoftKeyboard -import com.tangem.tap.common.KeyboardObserver -import com.tangem.tap.common.analytics.events.Token -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.getFromClipboard -import com.tangem.tap.common.extensions.setOnImeActionListener -import com.tangem.tap.common.recyclerView.SpaceItemDecoration -import com.tangem.tap.common.snackBar.MaxAmountSnackbar -import com.tangem.tap.common.text.truncateMiddleWith -import com.tangem.tap.common.toggleWidget.IndeterminateProgressButtonWidget -import com.tangem.tap.common.toggleWidget.ViewStateWidget -import com.tangem.tap.features.BaseStoreFragment -import com.tangem.tap.features.addBackPressHandler -import com.tangem.tap.features.send.redux.* -import com.tangem.tap.features.send.redux.AddressActionUi.* -import com.tangem.tap.features.send.redux.AmountActionUi.* -import com.tangem.tap.features.send.redux.FeeActionUi.* -import com.tangem.tap.features.send.redux.states.FeeType -import com.tangem.tap.features.send.redux.states.MainCurrencyType -import com.tangem.tap.features.send.ui.adapters.WarningMessagesAdapter -import com.tangem.tap.features.send.ui.stateSubscribers.SendStateSubscriber -import com.tangem.tap.store -import com.tangem.wallet.R -import com.tangem.wallet.databinding.FragmentSendBinding -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import java.text.DecimalFormatSymbols -import javax.inject.Inject - -private const val EDIT_TEXT_INPUT_DEBOUNCE = 400L - -/** -[REDACTED_AUTHOR] - */ -@Suppress("LargeClass") -@OptIn(FlowPreview::class) -@AndroidEntryPoint -class SendFragment : BaseStoreFragment(R.layout.fragment_send) { - - private val viewModel by viewModels() - - lateinit var sendBtn: ViewStateWidget - - private lateinit var etAmountToSend: TextInputEditText - private lateinit var warningsAdapter: WarningMessagesAdapter - - private val sendSubscriber = SendStateSubscriber(this) - private lateinit var keyboardObserver: KeyboardObserver - - val binding: FragmentSendBinding by viewBinding(FragmentSendBinding::bind) - - @Inject - lateinit var listenToQrScanningUseCase: ListenToQrScanningUseCase - - @Inject - lateinit var parseQrCodeUseCase: ParseQrCodeUseCase - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycle.addObserver(viewModel) - sendSubscriber.initViewModel(viewModel) - Analytics.send(Token.Send.ScreenOpened()) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - subscribeToTransactionExtrasFields() - subscribeToAddressField() - subscribeToAmountField() - } - } - - addBackPressHandler(this) - - etAmountToSend = view.findViewById(R.id.etAmountToSend) - - initSendButtonStates() - setupAddressLayout() - setupAmountLayout() - setupFeeLayout() - setupWarningMessages() - store.dispatch(SendActionUi.CheckIfTransactionDataWasProvided) - } - - private fun initSendButtonStates() = with(binding) { - btnSend.setOnClickListener { - store.dispatch( - SendActionUi.SendAmountToRecipient( - Message(getString(R.string.initial_message_sign_header)), - ), - ) - } - sendBtn = IndeterminateProgressButtonWidget(btnSend, progress) - } - - private fun setupAddressLayout() = with(binding.lSendAddress) { - store.dispatch(SetTruncateHandler { etAddress.truncateMiddleWith(it, "...") }) - store.dispatch(CheckClipboard(requireContext().getFromClipboard()?.toString())) - - etAddress.apply { - setOnSystemPasteButtonClickListener { - store.dispatch( - PasteAddress( - data = requireContext().getFromClipboard()?.toString() ?: "", - sourceType = Token.Send.AddressEntered.SourceType.PastePopup, - ), - ) - } - - setOnFocusChangeListener { _, hasFocus -> - store.dispatch(TruncateOrRestore(!hasFocus)) - } - } - - imvPaste.setOnClickListener { - Analytics.send(Token.Send.ButtonPaste()) - store.dispatch( - PasteAddress( - data = requireContext().getFromClipboard()?.toString() ?: "", - sourceType = Token.Send.AddressEntered.SourceType.PasteButton, - ), - ) - store.dispatch(TruncateOrRestore(!etAddress.isFocused)) - } - imvQrCode.setOnClickListener { - Analytics.send(Token.Send.ButtonQRCode()) - - store.dispatchNavigationAction { - push(AppRoute.QrScanning(source = SourceType.SEND)) - } - } - } - - private fun CoroutineScope.subscribeToAddressField() = with(binding.lSendAddress) { - etAddress.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { store.state.sendState.addressState.viewFieldValue.value != it } - .onEach { - store.dispatch(AddressActionUi.HandleUserInput(it)) - } - .launchIn(this@subscribeToAddressField) - } - - private fun CoroutineScope.subscribeToAmountField() { - etAmountToSend.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { store.state.sendState.amountState.viewAmountValue.value != it && it.isNotEmpty() } - .onEach { store.dispatch(AmountActionUi.HandleUserInput(it)) } - .launchIn(this) - } - - private fun CoroutineScope.subscribeToTransactionExtrasFields() = with(binding.lSendAddress) { - // TODO: [REDACTED_TASK_KEY] - etXlmMemo.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { - val info = store.state.sendState.transactionExtrasState - info.xlmMemo?.viewFieldValue?.value != it - } - .onEach { store.dispatch(TransactionExtrasAction.XlmMemo.HandleUserInput(it)) } - .launchIn(this@subscribeToTransactionExtrasFields) - - etDestinationTag.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { - val info = store.state.sendState.transactionExtrasState - info.xrpDestinationTag?.viewFieldValue?.value != it - } - .onEach { store.dispatch(TransactionExtrasAction.XrpDestinationTag.HandleUserInput(it)) } - .launchIn(this@subscribeToTransactionExtrasFields) - - etBinanceMemo.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { - val info = store.state.sendState.transactionExtrasState - info.binanceMemo?.viewFieldValue?.value != it - } - .onEach { store.dispatch(TransactionExtrasAction.BinanceMemo.HandleUserInput(it)) } - .launchIn(this@subscribeToTransactionExtrasFields) - - etTonMemo.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { - val info = store.state.sendState.transactionExtrasState - info.tonMemoState?.viewFieldValue?.value != it - } - .onEach { store.dispatch(TransactionExtrasAction.TonMemo.HandleUserInput(it)) } - .launchIn(this@subscribeToTransactionExtrasFields) - - etCosmosMemo.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { - val info = store.state.sendState.transactionExtrasState - info.cosmosMemoState?.viewFieldValue?.value != it - } - .onEach { store.dispatch(TransactionExtrasAction.CosmosMemo.HandleUserInput(it)) } - .launchIn(this@subscribeToTransactionExtrasFields) - - etHederaMemo.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { - val info = store.state.sendState.transactionExtrasState - info.hederaMemoState?.viewFieldValue?.value != it - } - .onEach { store.dispatch(TransactionExtrasAction.HederaMemo.HandleUserInput(it)) } - .launchIn(this@subscribeToTransactionExtrasFields) - - etAlgorandMemo.inputtedTextAsFlow() - .debounce(EDIT_TEXT_INPUT_DEBOUNCE) - .filter { - val info = store.state.sendState.transactionExtrasState - info.algorandMemoState?.viewFieldValue?.value != it - } - .onEach { store.dispatch(TransactionExtrasAction.AlgorandMemo.HandleUserInput(it)) } - .launchIn(this@subscribeToTransactionExtrasFields) - } - - private fun setupAmountLayout() { - store.dispatch(SetMainCurrency(restoreMainCurrency())) - store.dispatch(ReceiptAction.RefreshReceipt) - store.dispatch(SendAction.ChangeSendButtonState(store.state.sendState.getButtonState())) - - val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator.toString() - store.dispatch(AmountAction.SetDecimalSeparator(decimalSeparator)) - - binding.lSendAmount.tvAmountCurrency.setOnClickListener { - Analytics.send(Token.Send.ButtonSwapCurrency()) - store.dispatch(ToggleMainCurrency) - store.dispatch(ReceiptAction.RefreshReceipt) - store.dispatch(SendAction.ChangeSendButtonState(store.state.sendState.getButtonState())) - } - - val maxAmountSnackbar = MaxAmountSnackbar.make(etAmountToSend) { - etAmountToSend.clearFocus() - etAmountToSend.postDelayed(200) { etAmountToSend.hideSoftKeyboard() } - store.dispatch(SetMaxAmount) - } - var snackbarControlledByChangingFocus = false - keyboardObserver = KeyboardObserver(requireActivity()) - keyboardObserver.registerListener { isShow -> - if (snackbarControlledByChangingFocus) return@registerListener - - if (isShow) { - if (etAmountToSend.isFocused && !maxAmountSnackbar.isShown) maxAmountSnackbar.show() - } else { - if (maxAmountSnackbar.isShown) maxAmountSnackbar.dismiss() - } - } - - etAmountToSend.keyListener = DigitsKeyListener.getInstance("0123456789,.") - etAmountToSend.setOnFocusChangeListener { _, hasFocus -> - snackbarControlledByChangingFocus = true - if (hasFocus) { - etAmountToSend.postDelayed(200) { - maxAmountSnackbar.show() - snackbarControlledByChangingFocus = false - } - } else { - etAmountToSend.postDelayed(350) { - maxAmountSnackbar.dismiss() - snackbarControlledByChangingFocus = false - } - } - } - - val prevFocusChangeListener = etAmountToSend.onFocusChangeListener - etAmountToSend.setOnFocusChangeListener { v, hasFocus -> - prevFocusChangeListener.onFocusChange(v, hasFocus) - if (hasFocus && etAmountToSend.text?.toString() == "0") etAmountToSend.setText("") - if (!hasFocus && etAmountToSend.text?.toString() == "") etAmountToSend.setText("0") - } - - etAmountToSend.setOnImeActionListener(EditorInfo.IME_ACTION_DONE) { - it.hideSoftKeyboard() - it.clearFocus() - } - } - - private fun setupFeeLayout() = with(binding.clNetworkFee) { - flExpandCollapse.flExpandCollapse.setOnClickListener { - store.dispatch(ToggleControlsVisibility) - } - chipGroup.check(FeeUiHelper.toId(FeeType.NORMAL)) - chipGroup.setOnCheckedChangeListener { _, checkedId -> - if (checkedId == -1) return@setOnCheckedChangeListener - - store.dispatch(ChangeSelectedFee(FeeUiHelper.toType(checkedId))) - store.dispatch(CheckAmountToSend) - } - swIncludeFee.setOnCheckedChangeListener { _, isChecked -> - store.dispatch(ChangeIncludeFee(isChecked)) - store.dispatch(CheckAmountToSend) - } - } - - private fun setupWarningMessages() = with(binding) { - warningsAdapter = WarningMessagesAdapter() - val layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - rvWarningMessages.layoutManager = layoutManager - rvWarningMessages.addItemDecoration(SpaceItemDecoration.all(16f)) - rvWarningMessages.adapter = warningsAdapter - - store.dispatch(SendAction.Warnings.Update) - } - - override fun subscribeToStore() { - store.subscribe(sendSubscriber) { appState -> - appState.skipRepeats { oldState, newState -> - oldState.sendState == newState.sendState - }.select { it.sendState } - } - storeSubscribersList.add(sendSubscriber) - } - - private fun restoreMainCurrency(): MainCurrencyType { - val sp = requireContext().getSharedPreferences("SendScreen", Context.MODE_PRIVATE) - val mainCurrency = sp.getString("mainCurrency", AppCurrency.Default.code) - return MainCurrencyType.values() - .firstOrNull { it.name.equals(mainCurrency!!, ignoreCase = true) } - ?: MainCurrencyType.CRYPTO - } - - fun saveMainCurrency(type: MainCurrencyType) { - val sp = requireContext().getSharedPreferences("SendScreen", Context.MODE_PRIVATE) - sp.edit().putString("mainCurrency", type.name).apply() - } - - override fun handleOnBackPressed() { - val externalTransactionData = store.state.sendState.externalTransactionData - if (externalTransactionData == null) { - store.dispatchNavigationAction(AppRouter::pop) - } else { - store.dispatch(TradeCryptoAction.FinishSelling(externalTransactionData.transactionId)) - } - } - - override fun onDestroyView() { - keyboardObserver.unregisterListener() - super.onDestroyView() - } - - override fun onDestroy() { - store.dispatch(ReleaseSendState) - lifecycle.removeObserver(viewModel) - super.onDestroy() - } -} - -fun EditText.inputtedTextAsFlow(): Flow = callbackFlow { - val watcher = addTextChangedListener { editable -> trySend(editable?.toString() ?: "") } - awaitClose { removeTextChangedListener(watcher) } -} - -object FeeUiHelper { - fun toId(fee: FeeType): Int { - return when (fee) { - FeeType.SINGLE -> View.NO_ID - FeeType.LOW -> R.id.chipLow - FeeType.NORMAL -> R.id.chipNormal - FeeType.PRIORITY -> R.id.chipPriority - } - } - - fun toType(id: Int): FeeType { - return when (id) { - R.id.chipLow -> FeeType.LOW - R.id.chipNormal -> FeeType.NORMAL - R.id.chipPriority -> FeeType.PRIORITY - else -> FeeType.NORMAL - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/SendViewModel.kt b/app/src/main/java/com/tangem/tap/features/send/ui/SendViewModel.kt deleted file mode 100644 index 881e9f6df2..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/SendViewModel.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.tangem.tap.features.send.ui - -import androidx.lifecycle.* -import arrow.core.getOrElse -import com.tangem.common.routing.AppRoute -import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -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.tokens.FetchPendingTransactionsUseCase -import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase -import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.tap.common.analytics.events.Token -import com.tangem.tap.di.DelayedWork -import com.tangem.tap.features.send.redux.AddressActionUi -import com.tangem.tap.features.send.redux.AmountAction -import com.tangem.tap.proxy.AppStateHolder -import com.tangem.tap.store -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -@Suppress("LongParameterList") -@HiltViewModel -internal class SendViewModel @Inject constructor( - private val dispatchers: CoroutineDispatcherProvider, - private val appStateHolder: AppStateHolder, - private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, - private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, - private val listenToQrScanningUseCase: ListenToQrScanningUseCase, - private val parseQrCodeUseCase: ParseQrCodeUseCase, - @DelayedWork private val coroutineScope: CoroutineScope, - savedStateHandle: SavedStateHandle, -) : ViewModel(), DefaultLifecycleObserver { - - init { - listenToQrScanningUseCase(SourceType.SEND) - .getOrElse { emptyFlow() } - .onEach(::onQRCodeScanned) - .launchIn(viewModelScope) - } - - private val cryptoCurrency: CryptoCurrency? = savedStateHandle[AppRoute.Send.CRYPTO_CURRENCY_KEY] - - override fun onCreate(owner: LifecycleOwner) { - getBalanceHidingSettingsUseCase() - .flowWithLifecycle(owner.lifecycle) - .onEach { - appStateHolder.mainStore?.dispatch(AmountAction.HideBalance(it.isBalanceHidden)) - } - .flowOn(dispatchers.main) - .launchIn(viewModelScope) - } - - fun updateCurrencyDelayed() { - if (cryptoCurrency != null) { - coroutineScope.launch { - getSelectedWalletSyncUseCase() - .fold( - ifLeft = { Timber.e(it.toString()) }, - ifRight = { wallet -> - // we should update network to find pending tx after 1 sec - updateForPendingTx(wallet, cryptoCurrency.network) - // we should update network for new balance - updateForBalance(wallet, cryptoCurrency.network) - }, - ) - } - } else { - Timber.w("$TAG: cryptoCurrency is null, legacy flow") - } - } - - private suspend fun updateForPendingTx(userWallet: UserWallet, network: Network) { - fetchPendingTransactionsUseCase(userWallet.walletId, setOf(network)) - } - - private suspend fun updateForBalance(userWallet: UserWallet, network: Network) { - updateDelayedCurrencyStatusUseCase( - userWalletId = userWallet.walletId, - network = network, - delayMillis = UPDATE_BALANCE_DELAY_MILLIS, - refresh = true, - ) - } - - private fun onQRCodeScanned(qrScanResult: String) { - if (cryptoCurrency != null) { - parseQrCodeUseCase(qrScanResult, cryptoCurrency).fold( - ifRight = { parsedCode -> - store.dispatch( - AddressActionUi.PasteAddress( - data = parsedCode.address, - sourceType = Token.Send.AddressEntered.SourceType.QRCode, - ), - ) - parsedCode.amount?.let { amount -> - store.dispatch(AmountAction.SetAmount(amount, isUserInput = false)) - } - // parsedCode.memo?.let { } - }, - ifLeft = { - store.dispatch( - AddressActionUi.PasteAddress( - data = qrScanResult, - sourceType = Token.Send.AddressEntered.SourceType.QRCode, - ), - ) - Timber.w(it) - }, - ) - } else { - store.dispatch( - AddressActionUi.PasteAddress( - data = qrScanResult, - sourceType = Token.Send.AddressEntered.SourceType.QRCode, - ), - ) - } - - store.dispatch(AddressActionUi.TruncateOrRestore(truncate = true)) - } - - companion object { - private const val UPDATE_BALANCE_DELAY_MILLIS = 11000L - private const val TAG = "SendViewModel" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/adapters/WarningMessagesAdapter.kt b/app/src/main/java/com/tangem/tap/features/send/ui/adapters/WarningMessagesAdapter.kt deleted file mode 100644 index 2703f44c00..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/adapters/WarningMessagesAdapter.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.tangem.tap.features.send.ui.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.google.android.play.core.review.ReviewManagerFactory -import com.tangem.core.analytics.Analytics -import com.tangem.tap.common.analytics.events.AnalyticsParam -import com.tangem.tap.common.analytics.events.MainScreen -import com.tangem.tap.common.extensions.* -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.configurable.warningMessage.WarningMessage -import com.tangem.tap.store -import com.tangem.wallet.R -import com.tangem.wallet.databinding.LayoutWarningCardActionBinding -import timber.log.Timber - -// TODO: Delete with SendFeatureToggles -@Deprecated(message = "Used only in old send screen") -class WarningMessagesAdapter : ListAdapter(DiffUtilCallback) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WarningMessageVH { - val binding = LayoutWarningCardActionBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false, - ) - return WarningMessageVH(binding) - } - - override fun onBindViewHolder(holder: WarningMessageVH, position: Int) { - holder.bind(currentList[position]) - } - - object DiffUtilCallback : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: WarningMessage, newItem: WarningMessage) = oldItem == newItem - - override fun areItemsTheSame(oldItem: WarningMessage, newItem: WarningMessage) = oldItem == newItem - } -} - -class WarningMessageVH(val binding: LayoutWarningCardActionBinding) : RecyclerView.ViewHolder(binding.root) { - - fun bind(warning: WarningMessage) { - setBgColor(warning.priority) - setText(warning) - setupControlButtons(warning) - } - - private fun setText(warning: WarningMessage) = with(binding.warningContentContainer) { - fun getString(resId: Int?, default: String, formatArgs: String? = null) = - if (resId == null) default else root.getString(resId, formatArgs) - - tvTitle.text = getString( - resId = warning.titleResId, - default = warning.title, - formatArgs = warning.titleFormatArg, - ) - - tvMessage.text = getString( - resId = warning.messageResId, - default = warning.message, - formatArgs = warning.messageFormatArg, - ) - } - - private fun setBgColor(priority: WarningMessage.Priority) { - val color = when (priority) { - WarningMessage.Priority.Info -> R.color.warning_info - WarningMessage.Priority.Warning -> R.color.warning_warning - WarningMessage.Priority.Critical -> R.color.warning_critical - } - binding.warningCardAction.setCardBackgroundColor(binding.root.getColor(color)) - } - - @Suppress("LongMethod") - private fun setupControlButtons(warning: WarningMessage) = when (warning.type) { - WarningMessage.Type.Permanent, WarningMessage.Type.TestCard -> { - binding.groupControlsTemporary.hide() - binding.btnClose.hide() - } - WarningMessage.Type.Temporary -> { - binding.groupControlsTemporary.show() - binding.btnClose.hide() - - val buttonAction = View.OnClickListener { - store.dispatch(GlobalAction.HideWarningMessage(warning)) - } - val buttonTitle = binding.root.getString( - warning.buttonTextId ?: R.string.how_to_got_it_button, - ) - binding.btnGotIt.setOnClickListener(buttonAction) - binding.btnGotIt.text = buttonTitle - } - WarningMessage.Type.AppRating -> { - binding.groupControlsTemporary.hide() - binding.btnClose.show() - binding.btnClose.setOnClickListener { - Analytics.send(MainScreen.NoticeRateAppButton(AnalyticsParam.RateApp.Closed)) - store.dispatch(GlobalAction.HideWarningMessage(warning)) - } - binding.btnReallyCool.setOnClickListener { - val activity = binding.root.context.getActivity() ?: return@setOnClickListener - - Analytics.send(MainScreen.NoticeRateAppButton(AnalyticsParam.RateApp.Liked)) - val reviewManager = ReviewManagerFactory.create(activity) - val task = reviewManager.requestReviewFlow() - task.addOnCompleteListener { - if (!it.isSuccessful) { - Timber.e(task.exception) - } - }.addOnFailureListener { - Timber.e(it) - } - store.dispatch(GlobalAction.HideWarningMessage(warning)) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/ChiaWarningDialog.kt b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/ChiaWarningDialog.kt deleted file mode 100644 index b4ba056146..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/ChiaWarningDialog.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.tap.features.send.ui.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.store -import com.tangem.wallet.R - -// TODO: [REDACTED_JIRA] -object ChiaWarningDialog { - - fun create(context: Context, dialog: SendAction.Dialog.ChiaWarningDialog): AlertDialog { - return AlertDialog.Builder(context).apply { - setTitle(R.string.common_warning) - setMessage( - context.getString( - R.string.common_utxo_validate_withdrawal_message_warning, - dialog.blockchainName, - dialog.maxOutputs, - dialog.maxAmount.toPlainString(), - ), - ) - setPositiveButton(R.string.common_ok) { _, _ -> - dialog.onOk() - } - setOnDismissListener { - store.dispatch(SendAction.Dialog.Hide) - } - } - .create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/KaspaWarningDialog.kt b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/KaspaWarningDialog.kt deleted file mode 100644 index a86e233c8b..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/KaspaWarningDialog.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.tangem.tap.features.send.ui.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.blockchain.common.Blockchain -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.store -import com.tangem.wallet.R - -object KaspaWarningDialog { - - fun create(context: Context, dialog: SendAction.Dialog.KaspaWarningDialog): AlertDialog { - return AlertDialog.Builder(context).apply { - setTitle(R.string.common_warning) - setMessage( - context.getString( - R.string.common_utxo_validate_withdrawal_message_warning, - Blockchain.Kaspa.fullName, - dialog.maxOutputs, - dialog.maxAmount.toPlainString(), - ), - ) - setPositiveButton(R.string.common_ok) { _, _ -> - dialog.onOk() - } - setOnDismissListener { - store.dispatch(SendAction.Dialog.Hide) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/RequestFeeErrorDialog.kt b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/RequestFeeErrorDialog.kt deleted file mode 100644 index bb8a5ea183..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/RequestFeeErrorDialog.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.tap.features.send.ui.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.core.analytics.Analytics -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic -import com.tangem.tap.common.feedback.SendTransactionFailedEmail -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.store -import com.tangem.wallet.R - -/** -[REDACTED_AUTHOR] - */ -object RequestFeeErrorDialog { - fun create(context: Context, dialog: SendAction.Dialog.RequestFeeError): AlertDialog { - val errorMessage = dialog.error.customMessage - - return AlertDialog.Builder(context).apply { - setTitle(R.string.common_fee_error) - setMessage(context.getString(R.string.alert_failed_to_send_transaction_message, errorMessage)) - setNegativeButton(R.string.details_row_title_contact_to_support) { _, _ -> - Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Send)) - store.dispatch( - GlobalAction.SendEmail( - feedbackData = SendTransactionFailedEmail(errorMessage), - scanResponse = dialog.scanResponse, - ), - ) - } - setPositiveButton(R.string.common_retry) { _, _ -> dialog.onRetry() } - setNeutralButton(R.string.common_cancel) { _, _ -> } - setOnDismissListener { store.dispatch(SendAction.Dialog.Hide) } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/SendTransactionFailsDialog.kt b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/SendTransactionFailsDialog.kt deleted file mode 100644 index a768ea033b..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/SendTransactionFailsDialog.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.tangem.tap.features.send.ui.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.BlockchainSdkError -import com.tangem.common.module.ModuleMessageConverter -import com.tangem.core.analytics.Analytics -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.sdk.extensions.localizedDescription -import com.tangem.tap.common.extensions.stripZeroPlainString -import com.tangem.tap.common.feedback.SendTransactionFailedEmail -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.store -import com.tangem.wallet.R - -/** -[REDACTED_AUTHOR] - */ -object SendTransactionFailsDialog { - fun create(context: Context, dialog: SendAction.Dialog.SendTransactionFails.CardSdkError): AlertDialog { - return create(context, dialog.error.localizedDescription(context), dialog.scanResponse) - } - - fun create(context: Context, dialog: SendAction.Dialog.SendTransactionFails.BlockchainSdkError): AlertDialog { - val errorConverter = BlockchainSdkErrorConverter(context) - return create(context, errorConverter.convert(dialog.error), dialog.scanResponse) - } - - private fun create(context: Context, errorMessage: String, scanResponse: ScanResponse): AlertDialog { - return AlertDialog.Builder(context).apply { - setTitle(R.string.alert_failed_to_send_transaction_title) - setMessage(context.getString(R.string.alert_failed_to_send_transaction_message, errorMessage)) - setNeutralButton(R.string.details_row_title_contact_to_support) { _, _ -> - Analytics.send(Basic.ButtonSupport(AnalyticsParam.ScreensSources.Send)) - store.dispatch(GlobalAction.SendEmail(SendTransactionFailedEmail(errorMessage), scanResponse)) - } - setPositiveButton(R.string.common_cancel) { _, _ -> } - setOnDismissListener { store.dispatch(SendAction.Dialog.Hide) } - }.create() - } -} - -private class BlockchainSdkErrorConverter( - private val context: Context, -) : ModuleMessageConverter { - - override fun convert(message: BlockchainSdkError): String { - return when (message) { - is BlockchainSdkError.CreateAccountUnderfunded -> { - val resStringId = when (message.blockchain) { - Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.string.no_account_polkadot - else -> R.string.send_error_no_target_account - } - val reserveValueString = message.minReserve.value?.stripZeroPlainString() ?: "0" - val argument = "$reserveValueString ${message.minReserve.currencySymbol}" - context.getString(resStringId, argument) - } - else -> message.customMessage - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/TezosWarningDialog.kt b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/TezosWarningDialog.kt deleted file mode 100644 index afb6276cb7..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/TezosWarningDialog.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tangem.tap.features.send.ui.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.store -import com.tangem.wallet.R - -class TezosWarningDialog(context: Context) : AlertDialog(context) { - - companion object { - fun create(context: Context, showDialogData: SendAction.Dialog.TezosWarningDialog): AlertDialog { - val reduceAmount = showDialogData.reduceAmount.toPlainString() - return Builder(context).apply { - setTitle(R.string.common_warning) - setMessage(context.getString(R.string.xtz_withdrawal_message_warning, reduceAmount)) - setNegativeButton(R.string.xtz_withdrawal_message_ignore) { _, _ -> - showDialogData.sendAllCallback() - } - setPositiveButton(context.getString(R.string.xtz_withdrawal_message_reduce, reduceAmount)) { _, _ -> - showDialogData.reduceCallback() - } - setOnDismissListener { - store.dispatch(SendAction.Dialog.Hide) - } - }.create() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/FragmentStateSubscriber.kt b/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/FragmentStateSubscriber.kt deleted file mode 100644 index 17dfb7a5fc..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/FragmentStateSubscriber.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.tap.features.send.ui.stateSubscribers - -import com.tangem.tap.features.BaseStoreFragment -import org.rekotlin.StateType -import org.rekotlin.StoreSubscriber -import java.lang.ref.WeakReference - -/** -[REDACTED_AUTHOR] - */ -abstract class FragmentStateSubscriber(fragment: BaseStoreFragment) : StoreSubscriber { - private val weakFragment: WeakReference = WeakReference(fragment) - - abstract fun updateWithNewState(fg: BaseStoreFragment, state: S) - - override fun newState(state: S) { - val fg = weakFragment.get() ?: return - - updateWithNewState(fg, state) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/SendStateSubscriber.kt b/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/SendStateSubscriber.kt deleted file mode 100644 index 81899d8e65..0000000000 --- a/app/src/main/java/com/tangem/tap/features/send/ui/stateSubscribers/SendStateSubscriber.kt +++ /dev/null @@ -1,530 +0,0 @@ -package com.tangem.tap.features.send.ui.stateSubscribers - -import android.app.Dialog -import android.content.Context -import android.text.SpannableStringBuilder -import android.view.View -import android.view.ViewGroup -import androidx.core.text.bold -import com.tangem.common.extensions.remove -import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.tap.common.entities.ProgressState -import com.tangem.tap.common.extensions.* -import com.tangem.tap.common.redux.getMessageString -import com.tangem.tap.common.text.DecimalDigitsInputFilter -import com.tangem.tap.domain.MultiMessageError -import com.tangem.tap.domain.assembleErrors -import com.tangem.tap.features.BaseStoreFragment -import com.tangem.tap.features.send.redux.AddressVerifyAction.Error -import com.tangem.tap.features.send.redux.SendAction -import com.tangem.tap.features.send.redux.states.* -import com.tangem.tap.features.send.ui.FeeUiHelper -import com.tangem.tap.features.send.ui.SendFragment -import com.tangem.tap.features.send.ui.SendViewModel -import com.tangem.tap.features.send.ui.adapters.WarningMessagesAdapter -import com.tangem.tap.features.send.ui.dialogs.* -import com.tangem.wallet.R - -/** -[REDACTED_AUTHOR] - */ -@Suppress("LargeClass") -internal class SendStateSubscriber( - fragment: BaseStoreFragment, -) : FragmentStateSubscriber(fragment) { - - private var dialog: Dialog? = null - private var sendViewModel: SendViewModel? = null - fun initViewModel(viewModel: SendViewModel) { - sendViewModel = viewModel - } - - override fun updateWithNewState(fg: BaseStoreFragment, state: SendState) { - fg.view ?: return - if (fg !is SendFragment) return - if (state.isSuccessSend) { - sendViewModel?.updateCurrencyDelayed() - return - } - - val lastChangedStates = state.lastChangedStates.toList() - state.lastChangedStates.clear() - (fg as? SendFragment)?.binding?.mainSendContainer?.beginDelayedTransition() - lastChangedStates.forEach { - when (it) { - StateId.SEND_SCREEN -> handleSendScreen(fg, state) - StateId.ADDRESS_PAY_ID -> handleAddressState(fg, state.addressState) - StateId.TRANSACTION_EXTRAS -> handleTransactionExtrasState(fg, state.transactionExtrasState) - StateId.AMOUNT -> handleAmountState(fg, state.amountState) - StateId.FEE -> handleFeeState(fg, state.feeState) - StateId.RECEIPT -> handleReceiptState(fg, state.receiptState, state.feeState.progressState) - } - } - } - - @Deprecated("Legacy") - @Suppress("ComplexMethod", "LongMethod") - private fun handleTransactionExtrasState(fg: SendFragment, infoState: TransactionExtrasState) = - with(fg.binding.lSendAddress) { - fun showView(view: View, info: Any?) { - view.show(info != null) - } - showView(xlmMemoContainer, infoState.xlmMemo) - showView(xrpDestinationTagContainer, infoState.xrpDestinationTag) - showView(binanceMemoContainer, infoState.binanceMemo) - showView(tonMemoContainer, infoState.tonMemoState) - showView(cosmosMemoContainer, infoState.cosmosMemoState) - showView(hederaMemoContainer, infoState.hederaMemoState) - showView(algorandMemoContainer, infoState.algorandMemoState) - - infoState.xlmMemo?.let { - if (!it.viewFieldValue.isFromUserInput) etXlmMemo.setText(it.viewFieldValue.value) - if (it.error != null) { - if (it.error == TransactionExtraError.INVALID_XLM_MEMO) { - tilXlmMemo.error = fg.getText(R.string.send_extras_error_invalid_memo) - } - } else { - tilXlmMemo.error = null - } - } - infoState.xrpDestinationTag?.let { - if (infoState.xrpDestinationTag.error != null) { - if (infoState.xrpDestinationTag.error == TransactionExtraError.INVALID_DESTINATION_TAG) { - tilDestinationTag.error = - fg.getText(R.string.send_extras_error_invalid_destination_tag) - } - } else { - tilDestinationTag.error = null - } - if (!it.viewFieldValue.isFromUserInput) { - etDestinationTag.setText(it.viewFieldValue.value) - } - } - infoState.binanceMemo?.let { - if (infoState.binanceMemo.error != null) { - if (infoState.binanceMemo.error == TransactionExtraError.INVALID_BINANCE_MEMO) { - tilBinanceMemo.error = fg.getText(R.string.send_extras_error_invalid_memo) - } - } else { - tilBinanceMemo.error = null - } - if (!it.viewFieldValue.isFromUserInput) { - etBinanceMemo.setText(it.viewFieldValue.value) - } - } - infoState.tonMemoState?.let { - if (infoState.tonMemoState.error != null) { - tilTonMemo.error = fg.getText(R.string.send_extras_error_invalid_memo) - } else { - tilBinanceMemo.error = null - } - if (!it.viewFieldValue.isFromUserInput) { - etTonMemo.setText(it.viewFieldValue.value) - } - } - infoState.cosmosMemoState?.let { - if (infoState.cosmosMemoState.error != null) { - tilCosmosMemo.error = fg.getText(R.string.send_extras_error_invalid_memo) - } else { - tilBinanceMemo.error = null - } - if (!it.viewFieldValue.isFromUserInput) { - etCosmosMemo.setText(it.viewFieldValue.value) - } - } - infoState.hederaMemoState?.let { - if (infoState.hederaMemoState.error != null) { - tilHederaMemo.error = fg.getText(R.string.send_extras_error_invalid_memo) - } else { - tilHederaMemo.error = null - } - if (!it.viewFieldValue.isFromUserInput) { - etHederaMemo.setText(it.viewFieldValue.value) - } - } - infoState.algorandMemoState?.let { - if (infoState.algorandMemoState.error != null) { - tilAlgorandMemo.error = fg.getText(R.string.send_extras_error_invalid_memo) - } else { - tilAlgorandMemo.error = null - } - if (!it.viewFieldValue.isFromUserInput) { - etAlgorandMemo.setText(it.viewFieldValue.value) - } - } - } - - @Suppress("ComplexMethod") - private fun handleSendScreen(fg: SendFragment, state: SendState) = with(fg.binding) { - when (state.dialog) { - is SendAction.Dialog.TezosWarningDialog -> { - if (dialog == null) { - dialog = TezosWarningDialog.create(fg.requireContext(), state.dialog) - dialog?.show() - } - } - is SendAction.Dialog.KaspaWarningDialog -> { - if (dialog == null) { - dialog = KaspaWarningDialog.create(fg.requireContext(), state.dialog) - dialog?.show() - } - } - is SendAction.Dialog.ChiaWarningDialog -> { - if (dialog == null) { - dialog = ChiaWarningDialog.create(fg.requireContext(), state.dialog) - dialog?.show() - } - } - is SendAction.Dialog.SendTransactionFails.CardSdkError -> { - if (dialog == null) { - dialog = SendTransactionFailsDialog.create(fg.requireContext(), state.dialog) - dialog?.show() - } - } - is SendAction.Dialog.SendTransactionFails.BlockchainSdkError -> { - if (dialog == null) { - dialog = SendTransactionFailsDialog.create(fg.requireContext(), state.dialog) - dialog?.show() - } - } - is SendAction.Dialog.RequestFeeError -> { - if (dialog == null) { - dialog = RequestFeeErrorDialog.create(fg.requireContext(), state.dialog) - dialog?.show() - } - } - else -> { - dialog?.dismiss() - dialog = null - } - } - - fg.sendBtn.changeState(state.sendButtonState.progressState) - fg.sendBtn.mainView.isEnabled = state.sendButtonState.enabled - - val rv = rvWarningMessages - val adapter = rv.adapter as? WarningMessagesAdapter ?: return - - adapter.submitList(state.sendWarningsList) - rv.show(state.sendWarningsList.isNotEmpty()) - - toolbar.title = fg.getString( - R.string.send_title_currency_format, - state.amountState.mainCurrency.currencySymbol, - ) - } - - private fun handleAddressState(fg: SendFragment, state: AddressState) { - with(fg.binding.lSendAddress) { - fun parseError(context: Context, error: Error?): String? { - val resId = when (error) { - Error.ADDRESS_INVALID_OR_UNSUPPORTED_BY_BLOCKCHAIN -> R.string.send_validation_invalid_address - Error.ADDRESS_SAME_AS_WALLET -> R.string.send_error_address_same_as_wallet - else -> null - } - return if (resId == null) null else context.getString(resId, "", "") - } - - imvPaste.isEnabled = state.pasteIsEnabled - - val et = etAddress - val til = tilAddress - val parsedError = parseError(til.context, state.error) - - til.isEnabled = state.inputIsEnabled - imvPaste.show(state.inputIsEnabled) - imvQrCode.show(state.inputIsEnabled) - flPaste.show(state.inputIsEnabled) - flQrCode.show(state.inputIsEnabled) - - til.hint = til.getString(R.string.send_destination_hint_address) - til.error = parsedError - til.isErrorEnabled = parsedError != null - til.helperText = state.destinationWalletAddress - til.isHelperTextEnabled = parsedError == null - - if (!state.viewFieldValue.isFromUserInput) et.update(state.viewFieldValue.value) - } - } - - private fun handleAmountState(fg: SendFragment, state: AmountState) = with(fg.binding.lSendAmount) { - if (state.error != null) { - val context = fg.requireContext() - val message = when (state.error) { - is MultiMessageError -> { - val multiError = state.error as MultiMessageError - val messageList = multiError.assembleErrors() - .map { getMessageString(context, it.first, it.second) } - multiError.builder(messageList) - } - else -> context.getString(state.error.messageResource) - } - tilAmountToSend.enableError(true, message) - } else { - tilAmountToSend.enableError(false) - } - - val filter = DecimalDigitsInputFilter( - digitsBeforeDecimal = 40, - digitsAfterDecimal = state.maxLengthOfAmount, - decimalSeparator = state.decimalSeparator, - ) - etAmountToSend.filters = arrayOf(filter) - val amountToSend = state.viewAmountValue - if (!amountToSend.isFromUserInput) etAmountToSend.update(amountToSend.value) - - tvAmountCurrency.update(state.mainCurrency.currencySymbol) - (fg as? SendFragment)?.saveMainCurrency(state.mainCurrency.type) - - val balanceText = when (state.mainCurrency.type) { - MainCurrencyType.FIAT -> fg.getString( - R.string.common_balance, - "${state.viewBalanceValue} ${state.mainCurrency.currencySymbol}", - ).remove(":") - MainCurrencyType.CRYPTO -> fg.getString( - R.string.common_balance, - "${state.mainCurrency.currencySymbol} ${state.viewBalanceValue}", - ) - } - - tvBalance.update(balanceText) - - tilAmountToSend.isEnabled = state.inputIsEnabled - - val imageRes = if (state.inputIsEnabled) R.drawable.ic_arrows_up_down else 0 - tvAmountCurrency.setCompoundDrawablesWithIntrinsicBounds(0, 0, imageRes, 0) - val textColor = if (state.inputIsEnabled) R.color.accent else R.color.text_secondary - tvAmountCurrency.setTextColor(fg.getColor(textColor)) - } - - @Suppress("MagicNumber") - private fun handleFeeState(fg: SendFragment, state: FeeState) { - with(fg.binding.clNetworkFee) { - fg.view?.findViewById(R.id.clNetworkFee)?.show(state.mainLayoutIsVisible) - flExpandCollapse.imvExpandCollapse.rotation = if (state.controlsLayoutIsVisible) 0f else 180f - llFeeControlsContainer.show(state.controlsLayoutIsVisible) - chipGroup.show(state.feeChipGroupIsVisible) - - swIncludeFee.isEnabled = state.includeFeeSwitcherIsEnabled - if (swIncludeFee.isChecked != state.feeIsIncluded) { - swIncludeFee.isChecked = state.feeIsIncluded - } - - val chipId = FeeUiHelper.toId(state.selectedFeeType) - if (chipGroup.checkedChipId != chipId && chipId != View.NO_ID) chipGroup.check(chipId) - } - with(fg.binding.clReceiptContainer) { - when (state.progressState) { - ProgressState.Loading -> { - tvReceiptFeeValue.hide() - pbReceiptFee.show() - } - ProgressState.Done -> { - pbReceiptFee.hide() - tvReceiptFeeValue.show() - } - else -> {} - } - } - } - - @Suppress("LongMethod", "ComplexMethod") - private fun handleReceiptState(fg: SendFragment, state: ReceiptState, feeProgressState: ProgressState) { - with(fg.binding.clReceiptContainer) { - val mainLayout = clReceiptContainer as ViewGroup - val totalLayout = llTotalContainer.llTotal as ViewGroup - val totalTokenLayout = llTotalContainer.flTotalTokenCrypto as ViewGroup - - fun getString(id: Int, vararg formatStrings: String): String = mainLayout.getString(id, *formatStrings) - - fun roughOrEmpty(value: String): String { - return if (value == BigDecimalFormatter.EMPTY_BALANCE_SIGN) value else "$ROUGH_SIGN $value" - } - - when (feeProgressState) { - ProgressState.Loading -> { - tvReceiptFeeValue.hide() - pbReceiptFee.show() - } - ProgressState.Done -> { - pbReceiptFee.hide() - tvReceiptFeeValue.show() - } - else -> {} - } - - when (state.visibleTypeOfReceipt) { - ReceiptLayoutType.FIAT -> { - val receipt = state.fiat ?: return - - totalLayout.show(true) - totalTokenLayout.show(false) - tvReceiptAmountValue.update("${receipt.amountFiat} ${receipt.symbols.fiat}") - tvReceiptFeeValue.update("${receipt.feeFiat} ${receipt.symbols.fiat}") - llTotalContainer.tvTotalValue.post { - llTotalContainer.tvTotalValue.update( - "${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}", - ) - } - - val willSent = getString( - R.string.send_total_subtitle_format, - "${receipt.willSentCrypto} ${receipt.symbols.crypto}", - ) - llTotalContainer.tvWillBeSentValue.update(willSent) - } - ReceiptLayoutType.CRYPTO -> { - val receipt = state.crypto ?: return - - totalLayout.show(true) - totalTokenLayout.show(false) - tvReceiptAmountValue.update("${receipt.amountCrypto} ${receipt.symbols.crypto}") - tvReceiptFeeValue.update("${receipt.feeCrypto} ${receipt.symbols.crypto}") - llTotalContainer.tvTotalValue.post { - llTotalContainer.tvTotalValue.update("${receipt.totalCrypto} ${receipt.symbols.crypto}") - } - - if (receipt.willSentFiat == BigDecimalFormatter.EMPTY_BALANCE_SIGN) { - llTotalContainer.tvWillBeSentValue.hide() - } else { - llTotalContainer.tvWillBeSentValue.show() - llTotalContainer.tvWillBeSentValue.update( - getString( - R.string.send_total_subtitle_fiat_format, - "${receipt.willSentFiat} ${receipt.symbols.fiat}", - "${receipt.feeFiat} ${receipt.symbols.fiat}", - ), - ) - } - } - ReceiptLayoutType.TOKEN_FIAT -> { - val receipt = state.tokenFiat ?: return - - totalLayout.show(true) - totalTokenLayout.show(false) - tvReceiptAmountValue.update("${receipt.amountFiat} ${receipt.symbols.fiat}") - tvReceiptFeeValue.update("${receipt.feeFiat} ${receipt.symbols.fiat}") - llTotalContainer.tvTotalValue.post { - llTotalContainer.tvTotalValue.update( - "${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}", - ) - } - - val willSent = getString( - R.string.send_total_subtitle_asset_format, - "${receipt.symbols.token ?: ""} ${receipt.willSentToken}", - "${receipt.symbols.crypto} ${receipt.willSentFeeCoin}", - ) - llTotalContainer.tvWillBeSentValue.update(willSent) - } - ReceiptLayoutType.TOKEN_CRYPTO -> { - val receipt = state.tokenCrypto ?: return - - totalLayout.show(false) - totalTokenLayout.show(true) - - tvReceiptAmountValue.update("${receipt.amountToken} ${receipt.symbols.token}") - tvReceiptFeeValue.update("${receipt.feeCoin} ${receipt.symbols.crypto}") - - val willSent = SpannableStringBuilder() - .bold { - append(roughOrEmpty(receipt.totalFiat)).append(" ") - append(receipt.symbols.fiat) - } - llTotalContainer.tvTotalTokenCryptoValue.update(willSent.toString()) - } - ReceiptLayoutType.FEE_IN_CUSTOM_TOKEN -> { - val receipt = state.customTokenCrypto ?: return - - totalLayout.show(false) - totalTokenLayout.show(true) - - tvReceiptAmountValue.update( - "${receipt.amountToken} ${receipt.symbols.token ?: receipt.symbols.crypto}", - ) - tvReceiptFeeValue.update("${receipt.feeCoin} ${receipt.symbols.fee}") - - val willSent = SpannableStringBuilder() - .bold { - append(roughOrEmpty(receipt.totalFiat)).append(" ") - append(receipt.symbols.fiat) - } - llTotalContainer.tvTotalTokenCryptoValue.update(willSent.toString()) - } - ReceiptLayoutType.FEE_IN_CUSTOM_TOKEN_FIAT -> { - val receipt = state.customTokenFiat ?: return - - totalLayout.show(true) - totalTokenLayout.show(false) - tvReceiptAmountValue.update("${receipt.amountFiat} ${receipt.symbols.fiat}") - tvReceiptFeeValue.update("${receipt.feeFiat} ${receipt.symbols.fiat}") - llTotalContainer.tvTotalValue.post { - llTotalContainer.tvTotalValue.update( - "${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}", - ) - } - - val willSent = getString( - R.string.send_total_subtitle_asset_format, - "${receipt.symbols.token ?: receipt.symbols.crypto} ${receipt.willSentToken}", - "${receipt.symbols.fee} ${receipt.willSentFeeCoin}", - ) - llTotalContainer.tvWillBeSentValue.update(willSent) - } - - ReceiptLayoutType.SAME_CURRENCY -> { - val receipt = state.sameCurrencyCrypto ?: return - - totalLayout.show(true) - totalTokenLayout.show(false) - tvReceiptAmountValue.update( - "${receipt.amountCrypto} ${receipt.symbols.token ?: receipt.symbols.crypto}", - ) - tvReceiptFeeValue.update("${receipt.feeCrypto} ${receipt.symbols.token ?: receipt.symbols.crypto}") - llTotalContainer.tvTotalValue.post { - llTotalContainer.tvTotalValue.update( - "${receipt.totalCrypto} ${receipt.symbols.token ?: receipt.symbols.crypto}", - ) - } - - if (receipt.willSentFiat == BigDecimalFormatter.EMPTY_BALANCE_SIGN) { - llTotalContainer.tvWillBeSentValue.hide() - } else { - llTotalContainer.tvWillBeSentValue.show() - llTotalContainer.tvWillBeSentValue.update( - getString( - R.string.send_total_subtitle_fiat_format, - "${receipt.willSentFiat} ${receipt.symbols.fiat}", - "${receipt.feeFiat} ${receipt.symbols.fiat}", - ), - ) - } - } - ReceiptLayoutType.SAME_CURRENCY_FIAT -> { - val receipt = state.sameCurrencyFiat ?: return - - totalLayout.show(true) - totalTokenLayout.show(false) - tvReceiptAmountValue.update("${receipt.amountFiat} ${receipt.symbols.fiat}") - tvReceiptFeeValue.update("${receipt.feeFiat} ${receipt.symbols.fiat}") - llTotalContainer.tvTotalValue.post { - llTotalContainer.tvTotalValue.update( - "${roughOrEmpty(receipt.totalFiat)} ${receipt.symbols.fiat}", - ) - } - - val willSent = getString( - R.string.send_total_subtitle_format, - "${receipt.willSentCrypto} ${receipt.symbols.token ?: receipt.symbols.crypto}", - ) - llTotalContainer.tvWillBeSentValue.update(willSent) - } - ReceiptLayoutType.UNKNOWN, null -> {} - } - } - } - - private companion object { - const val ROUGH_SIGN = "≈" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt b/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt index ac028fde72..0cf70b6b4f 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt @@ -4,7 +4,7 @@ import arrow.core.Either import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token import com.tangem.common.routing.AppRoute -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.card.DerivePublicKeysUseCase import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt index abb2a86125..cfb77e6180 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt @@ -1,8 +1,6 @@ package com.tangem.tap.features.wallet.redux.middlewares -import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager -import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Blockchain import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter @@ -17,11 +15,8 @@ import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppDialog import com.tangem.tap.common.redux.AppState -import com.tangem.tap.domain.TapError import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE -import com.tangem.tap.features.send.redux.PrepareSendScreen -import com.tangem.tap.features.send.redux.SendAction import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager import com.tangem.tap.network.exchangeServices.buyErc20TestnetTokens import com.tangem.tap.proxy.redux.DaggerGraphState @@ -45,9 +40,6 @@ object TradeCryptoMiddleware { } } - private val isSendRedesignedEnabled: Boolean - get() = store.inject(getDependency = DaggerGraphState::sendFeatureToggles).isRedesignedSendEnabled - @Suppress("LongMethod", "CyclomaticComplexMethod") private fun handle(state: () -> AppState?, action: TradeCryptoAction) { if (DemoHelper.tryHandle(state, action)) return @@ -62,20 +54,8 @@ object TradeCryptoMiddleware { cryptoCurrencyId = action.cryptoCurrencyId, yield = action.yield, ) - is TradeCryptoAction.SendToken -> { - if (isSendRedesignedEnabled) { - handleNewSendToken(action = action) - } else { - handleSendToken(action = action) - } - } - is TradeCryptoAction.SendCoin -> { - if (isSendRedesignedEnabled) { - handleNewSendCoin(action = action) - } else { - handleSendCoin(action = action) - } - } + is TradeCryptoAction.SendToken -> handleNewSendToken(action = action) + is TradeCryptoAction.SendCoin -> handleNewSendCoin(action = action) } } @@ -177,135 +157,6 @@ object TradeCryptoMiddleware { } } - private fun handleSendToken(action: TradeCryptoAction.SendToken) { - val currency = action.tokenCurrency - val blockchain = Blockchain.fromId(currency.network.id.value) - - scope.launch { - val walletManager = store.inject(DaggerGraphState::walletManagersFacade) - .getOrCreateWalletManager( - userWalletId = action.userWallet.walletId, - blockchain = blockchain, - derivationPath = currency.network.derivationPath.value, - ) - - if (walletManager == null) { - val error = TapError.UnsupportedState(stateError = "WalletManager is null") - FirebaseCrashlytics.getInstance().recordException(IllegalStateException(error.stateError)) - store.dispatchErrorNotification(error) - return@launch - } - - val sendableAmount = walletManager.wallet.amounts.values.firstOrNull { - val amountType = it.type - amountType is AmountType.Token && amountType.token.contractAddress == currency.contractAddress - } - - store.dispatchOnMain( - action = PrepareSendScreen( - walletManager = walletManager, - feePaidCurrency = walletManager.wallet.blockchain.feePaidCurrency(), - currency = currency, - coinAmount = walletManager.wallet.amounts[AmountType.Coin], - coinRate = action.coinFiatRate, - tokenAmount = sendableAmount, - tokenRate = action.tokenFiatRate, - feeCurrencyRate = action.feeCurrencyStatus?.value?.fiatRate, - feeCurrencyDecimals = action.feeCurrencyStatus?.currency?.decimals ?: 0, - ), - ) - - val txInfo = action.transactionInfo - if (txInfo != null) { - store.dispatchOnMain( - SendAction.SendSpecificTransaction( - sendAmount = txInfo.amount, - destinationAddress = txInfo.destinationAddress, - transactionId = txInfo.transactionId, - ), - ) - } - - val route = AppRoute.Send( - currency = currency, - userWalletId = action.userWallet.walletId, - ) - - store.dispatchNavigationAction { push(route) } - } - } - - private fun handleSendCoin(action: TradeCryptoAction.SendCoin) { - if (action.transactionInfo?.tag != null) { - // avoid open old send if memo exists - return - } - val cryptoStatus = action.coinStatus - val currency = cryptoStatus.currency - val blockchain = Blockchain.fromId(currency.network.id.value) - - scope.launch { - val walletManager = store.inject(DaggerGraphState::walletManagersFacade) - .getOrCreateWalletManager( - userWalletId = action.userWallet.walletId, - blockchain = blockchain, - derivationPath = currency.network.derivationPath.value, - ) - - if (walletManager == null) { - val error = TapError.UnsupportedState(stateError = "WalletManager is null") - FirebaseCrashlytics.getInstance().recordException(IllegalStateException(error.stateError)) - store.dispatchErrorNotification(error) - return@launch - } - - val sendableAmounts = walletManager.wallet.amounts.values.filter { it.type == AmountType.Coin } - when (currency) { - is CryptoCurrency.Coin -> { - val amountToSend = sendableAmounts.find { it.currencySymbol == currency.symbol } - - if (amountToSend == null) { - val error = TapError.UnsupportedState(stateError = "Amount to send is null") - FirebaseCrashlytics.getInstance() - .recordException(IllegalStateException(error.stateError)) - store.dispatchErrorNotification(error) - return@launch - } - - store.dispatchOnMain( - action = PrepareSendScreen( - walletManager = walletManager, - feePaidCurrency = walletManager.wallet.blockchain.feePaidCurrency(), - currency = currency, - coinAmount = amountToSend, - coinRate = cryptoStatus.value.fiatRate, - feeCurrencyRate = action.feeCurrencyStatus?.value?.fiatRate, - feeCurrencyDecimals = action.feeCurrencyStatus?.currency?.decimals ?: 0, - ), - ) - } - is CryptoCurrency.Token -> error("Action.tokenStatus.currency is Token") - } - - val txInfo = action.transactionInfo - if (txInfo != null) { - store.dispatchOnMain( - SendAction.SendSpecificTransaction( - sendAmount = txInfo.amount, - destinationAddress = txInfo.destinationAddress, - transactionId = txInfo.transactionId, - ), - ) - } - - val route = AppRoute.Send( - currency = currency, - userWalletId = action.userWallet.walletId, - ) - store.dispatchNavigationAction { push(route) } - } - } - private fun handleNewSendToken(action: TradeCryptoAction.SendToken) { handleNewSend( userWalletId = action.userWallet.walletId, diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt index f7b18ad5a2..99ddfe6b5b 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt @@ -1,23 +1,16 @@ package com.tangem.tap.network.auth -import com.tangem.datasource.config.ConfigManager import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.lib.auth.ExpressAuthProvider -import com.tangem.lib.auth.sessionId.ExpressSessionIdGenerator import java.util.UUID import java.util.concurrent.atomic.AtomicReference internal class DefaultExpressAuthProvider( private val userWalletsStore: UserWalletsStore, - private val configManager: ConfigManager, -) : ExpressAuthProvider, ExpressSessionIdGenerator { +) : ExpressAuthProvider { private var uuid = AtomicReference(UUID.randomUUID()) - override fun getApiKey(): String { - return configManager.config.express?.apiKey ?: error("No express api key provided") - } - override fun getUserId(): String { return userWalletsStore.selectedUserWalletOrNull?.walletId?.stringValue ?: error("No user id provided") } @@ -25,8 +18,4 @@ internal class DefaultExpressAuthProvider( override fun getSessionId(): String { return uuid.get().toString() } - - override fun generateNewSessionId() { - uuid = AtomicReference(UUID.randomUUID()) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index 102598b998..41aa591911 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -29,14 +29,8 @@ class AuthModule { @Provides @Singleton - fun provideExpressAuthProvider( - userWalletsStore: UserWalletsStore, - configManager: ConfigManager, - ): ExpressAuthProvider { - return DefaultExpressAuthProvider( - userWalletsStore = userWalletsStore, - configManager = configManager, - ) + fun provideExpressAuthProvider(userWalletsStore: UserWalletsStore): ExpressAuthProvider { + return DefaultExpressAuthProvider(userWalletsStore = userWalletsStore) } @Provides diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt index 041b9561ee..d7a9569c4c 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/CryptoCurrencyConverter.kt @@ -2,7 +2,7 @@ package com.tangem.tap.network.exchangeServices import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.tap.common.extensions.inject diff --git a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt index cfd1fc53f2..29b6c4b9d5 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -96,18 +96,27 @@ class TransactionManagerImpl( // for not EVM blockchains set gasLimit ZERO for now when (fee.data) { is TransactionFee.Single -> { - val normalFee = (fee.data as TransactionFee.Single).normal - val singleFee = if (normalFee as? Fee.CardanoToken != null) { - ProxyFee.CardanoToken( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - minAdaValue = normalFee.minAdaValue, - ) - } else { - ProxyFee.Common( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - ) + val singleFee = when (val normalFee = (fee.data as TransactionFee.Single).normal) { + is Fee.CardanoToken -> { + ProxyFee.CardanoToken( + gasLimit = BigInteger.ZERO, + fee = convertToProxyAmount(amount = normalFee.amount), + minAdaValue = normalFee.minAdaValue, + ) + } + is Fee.Filecoin -> { + ProxyFee.Filecoin( + gasLimit = BigInteger.ZERO, + fee = convertToProxyAmount(amount = normalFee.amount), + gasPremium = normalFee.gasPremium, + ) + } + else -> { + ProxyFee.Common( + gasLimit = BigInteger.ZERO, + fee = convertToProxyAmount(amount = normalFee.amount), + ) + } } ProxyFees.SingleFee(singleFee = singleFee) diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index ee435bc6ec..45b2035717 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -32,7 +32,6 @@ import com.tangem.feature.qrscanning.QrScanningRouter import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles import com.tangem.features.send.api.navigation.SendRouter import com.tangem.features.staking.api.navigation.StakingRouter import com.tangem.features.tester.api.TesterRouter @@ -64,7 +63,6 @@ data class DaggerGraphState( val balanceHidingRepository: BalanceHidingRepository? = null, val walletsRepository: WalletsRepository? = null, val networksRepository: NetworksRepository? = null, - val sendFeatureToggles: SendFeatureToggles? = null, val sendRouter: SendRouter? = null, val qrScanningRouter: QrScanningRouter? = null, val currenciesRepository: CurrenciesRepository? = null, diff --git a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt index aca4ce8efb..592656008e 100644 --- a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt +++ b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt @@ -7,6 +7,7 @@ import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import timber.log.Timber import kotlin.reflect.KClass internal class ProxyAppRouter( @@ -31,30 +32,35 @@ internal class ProxyAppRouter( override fun push(route: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Push route: $route") innerRouter.push(route, onComplete) } } override fun replaceAll(vararg routes: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Replace all routes with $routes") innerRouter.replaceAll(*routes, onComplete = onComplete) } } override fun pop(onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Pop route") innerRouter.pop(onComplete) } } override fun popTo(route: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Pop to route: $route") innerRouter.popTo(route, onComplete) } } override fun popTo(routeClass: KClass, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Pop to route class: $routeClass") innerRouter.popTo(routeClass, onComplete) } } 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 8b3c22a7f9..af1bebc286 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 @@ -9,6 +9,8 @@ import com.tangem.feature.walletsettings.component.WalletSettingsComponent import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent +import com.tangem.features.managetokens.ManageTokensToggles +import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter import com.tangem.features.send.api.navigation.SendRouter @@ -47,6 +49,7 @@ internal class ChildFactory @Inject constructor( private val detailsComponentFactory: DetailsComponent.Factory, private val walletSettingsComponentFactory: WalletSettingsComponent.Factory, private val disclaimerComponentFactory: DisclaimerComponent.Factory, + private val manageTokensComponentFactory: ManageTokensComponent.Factory, private val sendRouter: SendRouter, private val tokenDetailsRouter: TokenDetailsRouter, private val walletRouter: WalletRouter, @@ -55,6 +58,7 @@ internal class ChildFactory @Inject constructor( private val testerRouter: TesterRouter, private val detailsFeatureToggles: DetailsFeatureToggles, private val pushNotificationsFeatureToggles: PushNotificationsFeatureToggles, + private val manageTokensToggles: ManageTokensToggles, private val pushNotificationRouter: PushNotificationsRouter, ) { @@ -119,7 +123,21 @@ internal class ChildFactory @Inject constructor( route.asFragmentChild(Provider { HomeFragment() }) } is AppRoute.ManageTokens -> { - route.asFragmentChild(Provider { TokensListFragment() }) + if (manageTokensToggles.isFeatureEnabled) { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = ManageTokensComponent.Params( + mode = if (route.readOnlyContent) { + ManageTokensComponent.Mode.READ_ONLY + } else { + ManageTokensComponent.Mode.MANAGE + }, + ), + componentFactory = manageTokensComponentFactory, + ) + } else { + route.asFragmentChild(Provider { TokensListFragment() }) + } } is AppRoute.OnboardingNote -> { route.asFragmentChild(Provider { OnboardingNoteFragment() }) diff --git a/app/src/main/res/layout/fragment_send.xml b/app/src/main/res/layout/fragment_send.xml deleted file mode 100644 index a7f8c54c42..0000000000 --- a/app/src/main/res/layout/fragment_send.xml +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/layout_send_address.xml b/app/src/main/res/layout/layout_send_address.xml deleted file mode 100644 index 1256f33037..0000000000 --- a/app/src/main/res/layout/layout_send_address.xml +++ /dev/null @@ -1,329 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/layout_send_amount.xml b/app/src/main/res/layout/layout_send_amount.xml deleted file mode 100644 index b8f987e234..0000000000 --- a/app/src/main/res/layout/layout_send_amount.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_send_fee.xml b/app/src/main/res/layout/layout_send_fee.xml deleted file mode 100644 index 66c3b128f1..0000000000 --- a/app/src/main/res/layout/layout_send_fee.xml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_send_receipt.xml b/app/src/main/res/layout/layout_send_receipt.xml deleted file mode 100644 index 97e7afbb0f..0000000000 --- a/app/src/main/res/layout/layout_send_receipt.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_snackbar_max_amount.xml b/app/src/main/res/layout/view_snackbar_max_amount.xml deleted file mode 100644 index 141e31aa66..0000000000 --- a/app/src/main/res/layout/view_snackbar_max_amount.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_snackbar_max_amount_content.xml b/app/src/main/res/layout/view_snackbar_max_amount_content.xml deleted file mode 100644 index de8cdfa27e..0000000000 --- a/app/src/main/res/layout/view_snackbar_max_amount_content.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt index 6cb075061a..5ecd65378b 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt @@ -2,7 +2,7 @@ package com.tangem.tap.domain.card import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency diff --git a/build.gradle.kts b/build.gradle.kts index d92b770060..e71d217293 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -94,6 +94,11 @@ val generateComposeMetrics by tasks.registering { "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$outputDirectory", ) + // Compose strong skipping mode + // freeCompilerArgs.addAll( + // "-P", + // "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true", + // ) } } } 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 6cd73dfcb0..6c03778967 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 @@ -176,7 +176,11 @@ sealed class AppRoute(val path: String) : Route { } @Serializable - data object ManageTokens : AppRoute(path = "/manage_tokens") + data class ManageTokens( + val readOnlyContent: Boolean, + ) : AppRoute(path = "/manage_tokens/$readOnlyContent"), RouteBundleParams { + override fun getBundle(): Bundle = bundle(serializer()) + } @Serializable data object AddCustomToken : AppRoute(path = "/add_custom_token") diff --git a/common/ui-charts/build.gradle.kts b/common/ui-charts/build.gradle.kts index 319c15eebc..58bf27c3a5 100644 --- a/common/ui-charts/build.gradle.kts +++ b/common/ui-charts/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { implementation(deps.compose.material3) implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) + implementation(deps.kotlin.immutable.collections) } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt index 9bbaeaa48b..cd14d8f8c8 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt @@ -3,7 +3,6 @@ package com.tangem.common.ui.charts import android.content.res.Configuration import androidx.annotation.FloatRange import androidx.compose.foundation.background -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -11,45 +10,55 @@ import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontSynthesis import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.resolveAsTypeface 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.patrykandpatrick.vico.compose.cartesian.* +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis import com.patrykandpatrick.vico.compose.cartesian.axis.rememberCustomStartAxis +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState import com.patrykandpatrick.vico.compose.common.of import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom -import com.patrykandpatrick.vico.core.cartesian.axis.* +import com.patrykandpatrick.vico.core.cartesian.axis.AxisPosition +import com.patrykandpatrick.vico.core.cartesian.axis.BaseAxis +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter -import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget import com.patrykandpatrick.vico.core.common.Dimensions import com.patrykandpatrick.vico.core.common.component.LineComponent import com.patrykandpatrick.vico.core.common.shape.Shape +import com.tangem.common.ui.charts.layer.TimeItemPlacer import com.tangem.common.ui.charts.layer.rememberMarketChartLayer -import com.tangem.common.ui.charts.marker.rememberTangemChartMarker +import com.tangem.common.ui.charts.layer.rememberTangemChartMarker import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider import com.tangem.common.ui.charts.state.* import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toTimeFormat +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode @@ -64,79 +73,92 @@ private const val GUIDELINES_COUNT = 3 * @param splitChartSegmentColor The color of the grayed by marker chart segment. * @param backgroundSplitChartSegmentColorAlpha The alpha of the background the [splitChartSegmentColor] * @param backgroundColorAlpha The alpha of the background color of the chart. - * @param noChartContent A composable function that defines the content to be displayed when there is no data to display. */ @Composable fun MarketChart( modifier: Modifier = Modifier, state: MarketChartState = rememberMarketChartState(), - splitChartSegmentColor: Color, - @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float, - @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float, - noChartContent: @Composable BoxScope.() -> Unit, + splitChartSegmentColor: Color = TangemTheme.colors.icon.inactive, + @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float = 0.24f, + @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float = 0.24f, ) { var canvasWidth by remember { mutableIntStateOf(0) } - var canvasHeight by remember { mutableIntStateOf(0) } + var chartHeight by remember { mutableIntStateOf(0) } - val layer = rememberLayerFromState( - state = state, - splitChartSegmentColor = splitChartSegmentColor, - backgroundColorAlpha = backgroundColorAlpha, - backgroundSplitChartSegmentColorAlpha = backgroundSplitChartSegmentColorAlpha, - canvasHeight = canvasHeight, + val layer = rememberMarketChartLayer( + lineColor = state.chartColor, + backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha), + secondLineColor = splitChartSegmentColor, + backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha), + secondColorOnTheRightSide = state.markerHighlightRightSide.not(), + markerFraction = state.markerFraction, + axisValueOverrider = AxisValueOverrider.fixed(), + canvasHeight = chartHeight, ) + + val marker = rememberTangemChartMarker(color = state.chartColor) + val chart = rememberCartesianChart( layer, - startAxis = rememberMarketChartStartAxis( - yValueFormatter = state.yValueFormatter, - ), - bottomAxis = rememberMarketChartBottomAxis( - xValueFormatter = state.xValueFormatter, - ), + startAxis = rememberMarketChartStartAxis(state.yValueFormatter), + bottomAxis = rememberMarketChartBottomAxis(state.xValueFormatter), + horizontalLayout = HorizontalLayout.FullWidth(), + markerVisibilityListener = rememberMarketVisibilityListener(canvasWidth, state), + marker = marker, ) - val marker = rememberTangemChartMarker( - color = state.chartColor, - innerCircleColor = Color.White, - ) - val density = LocalDensity.current + + // we need to calculate what the overall height should be in order to get the correct height of the graph + val bottomAxisHeight = with(LocalDensity.current) { + getMarketChartBottomAxisHeight().toPx().toInt() + } CartesianChartHost( - modifier = modifier.onGloballyPositioned { - with(density) { + modifier = modifier + .onGloballyPositioned { canvasWidth = it.size.width - canvasHeight = if (it.size.height != 0) { - // FIXME get height bounded to min max chart points - it.size.height - 20.dp.toPx().toInt() - 27.dp.toPx().toInt() + chartHeight = if (it.size.height != 0) { + it.size.height - bottomAxisHeight } else { 0 } } - }, + // Sometimes the chart is not drawn correctly (ex. in LazyLayout), so we need to force the redraw + .drawBehind { + state.markerFraction + }, chart = chart, modelProducer = state.modelProducer, scrollState = rememberVicoScrollState(scrollEnabled = false), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - markerVisibilityListener = state.rememberMarketVisibilityListener(canvasWidth = canvasWidth), - diffAnimationSpec = null, - marker = marker, - placeholder = noChartContent, + animationSpec = null, ) } @Composable -private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int): CartesianMarkerVisibilityListener { - val state = this - return remember(state.markerVisibilityListener, canvasWidth) { +fun getMarketChartBottomAxisHeight(): Dp { + return with(LocalDensity.current) { + TangemTheme.typography.caption2.fontSize.toDp() + TangemTheme.dimens.spacing26 + } +} + +@Composable +private fun rememberMarketVisibilityListener( + canvasWidth: Int, + state: MarketChartState, +): CartesianMarkerVisibilityListener { + val haptic = LocalHapticManager.current + + return remember(state, canvasWidth) { val maxCanvasXFloat = canvasWidth.toFloat().takeIf { it != 0f } object : CartesianMarkerVisibilityListener { override fun onShown(marker: CartesianMarker, targets: List) { - state.stopDrawingAnimation() val xCanvas = (targets[0] as LineCartesianLayerMarkerTarget).canvasX state.markerFraction = maxCanvasXFloat?.let { xCanvas / it } state.markerVisibilityListener.onShown(marker, targets) + + haptic.perform(TangemHapticEffect.View.ContextClick) } override fun onHidden(marker: CartesianMarker) { @@ -149,38 +171,30 @@ private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int): state.markerFraction = maxCanvasXFloat?.let { xCanvas / it } state.markerVisibilityListener.onUpdated(marker, targets) + + haptic.perform(TangemHapticEffect.View.TextHandleMove) } } } } -@Composable -private fun rememberLayerFromState( - state: MarketChartState, - splitChartSegmentColor: Color, - @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float, - @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float, - canvasHeight: Int, -): LineCartesianLayer { - return rememberMarketChartLayer( - lineColor = state.chartColor, - backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha), - secondLineColor = splitChartSegmentColor, - backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha), - secondColorOnTheRightSide = state.markerHighlightRightSide.not(), - startDrawingAnimation = state.startDrawingAnimationState, - markerFraction = state.markerFraction, - axisValueOverrider = AxisValueOverrider.adaptiveYValues(yFraction = 1.2f, round = true), // FIXME ? - canvasHeight = canvasHeight, - ) -} - @Composable private fun rememberMarketChartStartAxis( yValueFormatter: CartesianValueFormatter, ): VerticalAxis { + val textStyle = TangemTheme.typography.caption2 + val resolver = LocalFontFamilyResolver.current + val typeface by remember(resolver, textStyle) { + resolver.resolveAsTypeface( + fontFamily = textStyle.fontFamily, + fontWeight = textStyle.fontWeight ?: FontWeight.Normal, + fontStyle = textStyle.fontStyle ?: FontStyle.Normal, + fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All, + ) + } + return rememberCustomStartAxis( - axis = null, + line = null, tick = null, guideline = null, labelGuideline = rememberChartAxisGuidelineComponent( @@ -194,38 +208,44 @@ private fun rememberMarketChartStartAxis( end = TangemTheme.dimens.spacing4, ), textSize = TangemTheme.typography.caption2.fontSize, - typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(), + typeface = typeface, ), horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Inside, verticalLabelPosition = VerticalAxis.VerticalLabelPosition.Center, - itemPlacer = AxisItemPlacer.Vertical.count({ GUIDELINES_COUNT }, false), + itemPlacer = VerticalAxis.ItemPlacer.count({ GUIDELINES_COUNT }, false), valueFormatter = yValueFormatter, ) } @Composable -fun rememberMarketChartBottomAxis( +private fun rememberMarketChartBottomAxis( xValueFormatter: CartesianValueFormatter, ): HorizontalAxis { + val textStyle = TangemTheme.typography.caption2 + + val resolver = LocalFontFamilyResolver.current + + val typeface by remember(resolver, textStyle) { + resolver.resolveAsTypeface( + fontFamily = textStyle.fontFamily, + fontWeight = textStyle.fontWeight ?: FontWeight.Normal, + fontStyle = textStyle.fontStyle ?: FontStyle.Normal, + fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All, + ) + } + return rememberBottomAxis( label = rememberAxisLabelComponent( color = TangemTheme.colors.text.tertiary, textSize = TangemTheme.typography.caption2.fontSize, - padding = Dimensions.of(top = TangemTheme.dimens.spacing20), - typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(), + padding = Dimensions.of(top = TangemTheme.dimens.spacing26), + typeface = typeface, ), tick = null, - axis = null, + line = null, guideline = null, - sizeConstraint = BaseAxis.SizeConstraint.Exact(sizeDp = 37f), // FIXME ? - itemPlacer = remember { - AxisItemPlacer.Horizontal.default( - spacing = 25, // FIXME ? - offset = 60, // FIXME ? - shiftExtremeTicks = false, - addExtremeLabelPadding = false, - ) - }, + sizeConstraint = BaseAxis.SizeConstraint.Auto(), + itemPlacer = remember { TimeItemPlacer() }, valueFormatter = xValueFormatter, ) } @@ -245,19 +265,6 @@ private fun rememberChartAxisGuidelineComponent(color: Color): LineComponent { ) } -@Composable -internal fun TextStyle.toGraphicsTypeFace(): android.graphics.Typeface { - val resolver = LocalFontFamilyResolver.current - return remember(resolver, this) { - resolver.resolveAsTypeface( - fontFamily = this.fontFamily, - fontWeight = this.fontWeight ?: FontWeight.Normal, - fontStyle = this.fontStyle ?: FontStyle.Normal, - fontSynthesis = this.fontSynthesis ?: FontSynthesis.All, - ) - }.value -} - // region Preview @Suppress("LongMethod") @@ -275,7 +282,6 @@ private fun MarketChartPreview( chartLook = MarketChartLook( type = MarketChartLook.Type.Growing, markerHighlightRightSide = true, - animationOnDataChange = true, ) } } @@ -283,13 +289,13 @@ private fun MarketChartPreview( LaunchedEffect(key1 = Unit) { dataProducer.runTransactionSuspend { chartData = MarketChartData.Data( - x = x, - y = y, + x = x.toImmutableList(), + y = y.toImmutableList(), ) updateLook { it.copy( xAxisFormatter = { value -> - value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd) + value.toLong().toTimeFormat(DateTimeFormatters.dateMMMdd) }, yAxisFormatter = { value -> value.setScale(3, RoundingMode.HALF_UP).toPlainString() @@ -308,6 +314,7 @@ private fun MarketChartPreview( TangemThemePreview { val growingColor = TangemTheme.colors.icon.accent val fallingColor = TangemTheme.colors.icon.warning + val neutralColor = TangemTheme.colors.icon.informative val chartState = rememberMarketChartState( dataProducer = dataProducer, @@ -318,6 +325,7 @@ private fun MarketChartPreview( when (it) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor + MarketChartLook.Type.Neutral -> neutralColor } }, ) @@ -338,13 +346,9 @@ private fun MarketChartPreview( splitChartSegmentColor = TangemTheme.colors.icon.inactive, backgroundSplitChartSegmentColorAlpha = 0.24f, backgroundColorAlpha = 0.24f, - noChartContent = { }, ) SpacerH16() - Button(onClick = { chartState.startDrawingAnimation() }) { - Text("Start drawing animation") - } Button( onClick = { dataProducer.runTransaction { @@ -358,41 +362,38 @@ private fun MarketChartPreview( text = "Change marker highlight side", ) } - Button(onClick = { - coroutineScope.launch { - dataProducer.runTransactionSuspend { - updateData { - MarketChartData.Data( - x = it.x, - y = it.y.reversed(), + Button( + onClick = { + coroutineScope.launch { + dataProducer.runTransactionSuspend { + updateData { + MarketChartData.Data( + x = it.x, + y = it.y.reversed().toImmutableList(), + ) + } + } + } + }, + ) { + Text("Change Data") + } + + Button( + onClick = { + dataProducer.runTransaction { + updateLook { + it.copy( + type = when (it.type) { + MarketChartLook.Type.Growing -> MarketChartLook.Type.Falling + MarketChartLook.Type.Falling -> MarketChartLook.Type.Neutral + MarketChartLook.Type.Neutral -> MarketChartLook.Type.Growing + }, ) } } - } - },) { - Text("Change Data") - } - Button(onClick = { - dataProducer.runTransaction { - updateLook { it.copy(animationOnDataChange = it.animationOnDataChange.not()) } - } - },) { - Text("Change animationOnDataChange = ${look.animationOnDataChange}") - } - - Button(onClick = { - dataProducer.runTransaction { - updateLook { - it.copy( - type = if (it.type == MarketChartLook.Type.Growing) { - MarketChartLook.Type.Falling - } else { - MarketChartLook.Type.Growing - }, - ) - } - } - },) { + }, + ) { Text("Change color type") } } diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt index d9902450d3..2e62078f0f 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt @@ -12,19 +12,21 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.* +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.common.shader.BrushShader +import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.common.shader.ColorShader import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.toImmutableList import kotlin.random.Random @Composable @@ -34,6 +36,7 @@ fun MarketChartMini( type: MarketChartLook.Type = MarketChartLook.Type.Growing, growingColor: Color = TangemTheme.colors.icon.accent, fallingColor: Color = TangemTheme.colors.icon.warning, + neutralColor: Color = TangemTheme.colors.icon.informative, ) { val model = remember(rawData) { CartesianChartModel(LineCartesianLayerModel.build { series(rawData.y) }) @@ -42,20 +45,22 @@ fun MarketChartMini( val lineColor = when (type) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor + MarketChartLook.Type.Neutral -> neutralColor } - val lineSpec = rememberLineSpec( + val lineSpec = rememberLine( shader = ColorShader(lineColor.toArgb()), thickness = 1.dp, - backgroundShader = BrushShader( - brush = Brush.verticalGradient( - colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent), - ), - ), + backgroundShader = Brush.verticalGradient( + colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent), + ).toDynamicShader(), ) - val layer = rememberLineCartesianLayer(listOf(lineSpec)) - val chart = rememberCartesianChart(layer) + val layer = rememberLineCartesianLayer(LineCartesianLayer.LineProvider.series(lineSpec)) + val chart = rememberCartesianChart( + layer, + horizontalLayout = HorizontalLayout.fullWidth(), + ) CartesianChartHost( modifier = modifier, @@ -63,7 +68,6 @@ fun MarketChartMini( model = model, zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), scrollState = rememberVicoScrollState(scrollEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), ) } @@ -74,8 +78,8 @@ fun MarketChartMini( @Composable private fun Preview() { val data = MarketChartRawData( - x = List(20) { Random.nextFloat() }, - y = List(20) { Random.nextFloat() }, + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), ) TangemThemePreview { @@ -83,6 +87,8 @@ private fun Preview() { MarketChartMini(rawData = data, type = MarketChartLook.Type.Growing) SpacerH16() MarketChartMini(rawData = data, type = MarketChartLook.Type.Falling) + SpacerH16() + MarketChartMini(rawData = data, type = MarketChartLook.Type.Neutral) } } } @@ -92,8 +98,8 @@ private fun Preview() { @Composable private fun PreviewColumn() { val data = MarketChartRawData( - x = List(20) { Random.nextFloat() }, - y = List(20) { Random.nextFloat() }, + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), ) TangemThemePreview { diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt new file mode 100644 index 0000000000..21578b9cc1 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt @@ -0,0 +1,246 @@ +package com.tangem.common.ui.charts.downsample + +import kotlin.math.max + +/** + * ========================================================= + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= + * + * Downsamples the given data points to the desired number of buckets (points + 2). + * +[REDACTED_AUTHOR] + */ +object LTThreeBuckets { + + fun downsample(x: List, y: List, desiredBuckets: Int): Result { + require(x.size == y.size) { "X and Y must have the same size" } + require(desiredBuckets > 0) { "Desired buckets must be greater than 0" } + + val points = x.zip(y).mapIndexed { index, (x, y) -> Point(index, x, y) } + val results = mutableListOf() + + points.onPassBucketize(desiredBuckets) + .sliding(size = 3, step = 1) + .map { buckets -> Triangle.of(buckets) } + .fastForEach { triangle -> + if (results.isEmpty()) { + results.add(triangle.getFirst()) + } + + results.add(triangle.getResult()) + + if (results.size == desiredBuckets + 1) { + results.add(triangle.getLast()) + } + } + + val xRes = ArrayList(points.size) + val yRes = ArrayList(points.size) + val indexesRes = ArrayList(points.size) + + results.fastForEach { + xRes.add(it.x) + yRes.add(it.y) + indexesRes.add(it.originalIndex!!) + } + + return Result( + originalIndexes = indexesRes, + x = xRes, + y = yRes, + ) + } + + data class Result( + val originalIndexes: List, + val x: List, + val y: List, + ) +} + +private fun List.onPassBucketize(desiredBucketsCount: Int): List { + val middleSize = size - 2 + val bucketSize = middleSize / desiredBucketsCount + val remainingElements = middleSize % desiredBucketsCount + + require(bucketSize != 0) { + "Can't produce $desiredBucketsCount buckets from an input series of ${middleSize + 2} elements" + } + + val buckets = mutableListOf() + + // Add first point as the only point in the first bucket + buckets.add(Bucket.of(this[0])) + + var rest = this.subList(1, this.lastIndex) + + // Add middle buckets. + // When inputSize is not a multiple of desiredBuckets, + // remaining elements are equally distributed on the first buckets. + while (buckets.size < desiredBucketsCount + 1) { + val size = if (buckets.size <= remainingElements) bucketSize + 1 else bucketSize + buckets.add(Bucket.of(rest.subList(0, size))) + rest = rest.subList(size, rest.size) + } + + // Add last point as the only point in the last bucket + buckets.add(Bucket.of(this.last())) + + return buckets +} + +private fun List.sliding(size: Int, step: Int): List> { + val window = max(size, step) + val buffer = ArrayDeque() + var totalIn = 0 + + val lists = mutableListOf>() + + fastForEach { p -> + buffer.add(p) + ++totalIn + if (buffer.size == window) { + val batch = buffer.take(size) + lists.add(batch) + + repeat(step) { + buffer.removeFirst() + } + } + } + + if (buffer.isNotEmpty()) { + val totalOut = max(0, (totalIn + step - size - 1) / step) + 1 + if (totalOut > lists.size) { + val batch = buffer.take(size) + lists.add(batch) + } + } + + return lists +} + +private data class Point( + val originalIndex: Int? = null, + val x: Double, + val y: Double, +) + +private data class Bucket( + val data: List, + val center: Point, + val result: Point, + val first: Point, + val last: Point, +) { + companion object { + private fun centerBetweenPoints(a: Point, b: Point): Point { + val vector = Point( + x = b.x - a.x, + y = b.y - a.y, + ) + val halfVector = Point( + x = vector.x / 2, + y = vector.y / 2, + ) + + return Point( + x = a.x + halfVector.x, + y = a.y + halfVector.y, + ) + } + + fun of(points: List): Bucket { + val first = points.first() + val last = points.last() + + return Bucket( + data = points, + center = centerBetweenPoints(first, last), + result = first, + first = first, + last = last, + ) + } + + fun of(point: Point): Bucket { + return Bucket( + data = listOf(point), + center = point, + result = point, + first = point, + last = point, + ) + } + } +} + +private data class Triangle( + val left: Bucket, + val center: Bucket, + val right: Bucket, +) { + fun getResult(): Point { + return center.data.map { Area.ofTriangle(left.result, it, right.center) } + .maxByOrNull { it.value } + ?.generator + ?: error("Can't obtain max area triangle") + } + + fun getFirst(): Point { + return left.first + } + + fun getLast(): Point { + return right.last + } + + companion object { + fun of(buckets: List): Triangle { + return Triangle( + left = buckets[0], + center = buckets[1], + right = buckets[2], + ) + } + } +} + +private data class Area( + val generator: Point, + val value: Double, +) { + companion object { + fun ofTriangle(a: Point, b: Point, c: Point): Area { + val addends = listOf( + a.x * (b.y - c.y), + b.x * (c.y - a.y), + c.x * (a.y - b.y), + ) + val sum = addends.sum() + val value = kotlin.math.abs(sum / 2) + + return Area(b, value) + } + } +} + +private inline fun List.fastForEach(action: (T) -> Unit) { + for (index in indices) { + val item = get(index) + action(item) + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt new file mode 100644 index 0000000000..0005e47d15 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt @@ -0,0 +1,112 @@ +package com.tangem.common.ui.charts.layer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent +import com.patrykandpatrick.vico.compose.common.component.shapeComponent +import com.patrykandpatrick.vico.compose.common.of +import com.patrykandpatrick.vico.compose.common.shape.dashed +import com.patrykandpatrick.vico.core.cartesian.* +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerValueFormatter +import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker +import com.patrykandpatrick.vico.core.common.Dimensions +import com.patrykandpatrick.vico.core.common.LayeredComponent +import com.patrykandpatrick.vico.core.common.component.Component +import com.patrykandpatrick.vico.core.common.component.TextComponent +import com.patrykandpatrick.vico.core.common.shape.Shape +import com.tangem.core.ui.res.TangemTheme + +/** + * @param color The color of the indicator and guideline. + * @param innerCircleColor The color of the inner circle of the indicator. + * + * @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect. + */ +@Composable +internal fun rememberTangemChartMarker(color: Color): CartesianMarker { + val guideline = rememberUnboundedLineComponent( + color = color, + verticalAddDrawSpace = TangemTheme.dimens.spacing24, + shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) }, + ) + + return remember(guideline) { + val outColor = guideline.color + + object : DefaultCartesianMarker( + label = TextComponent(textSizeSp = 0f), + indicator = ::indicator, + indicatorSizeDp = INDICATOR_SIZE_DP, + guideline = guideline, + valueFormatter = object : CartesianMarkerValueFormatter { + override fun format( + context: CartesianDrawContext, + targets: List, + ): CharSequence = "" + }, + ) { + override fun updateInsets( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + model: CartesianChartModel, + insets: Insets, + ) { + with(context) { + super.updateInsets(context, horizontalDimensions, model, insets) + val baseShadowInsetDp = + CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP + val topInset = (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels + val bottomInset = (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels + insets.ensureValuesAtLeast(top = topInset, bottom = bottomInset) + } + } + + override fun CartesianDrawContext.drawIndicator(x: Float, y: Float, color: Int, halfIndicatorSize: Float) { + val indicator = indicator ?: return + cacheStore + .getOrSet(keyNamespace, indicator, outColor) { indicator.invoke(outColor) } + .draw( + this, + x - halfIndicatorSize, + y - halfIndicatorSize, + x + halfIndicatorSize, + y + halfIndicatorSize, + ) + } + } + } +} + +private fun indicator(color: Int): Component { + val composeColor = Color(color) + + return LayeredComponent( + rear = shapeComponent( + color = composeColor.copy(alpha = INDICATOR_REAR_COLOR_ALPHA), + shape = Shape.Pill, + ), + front = LayeredComponent( + rear = shapeComponent( + color = composeColor, + shape = Shape.Pill, + ), + front = shapeComponent( + color = Color.White, + shape = Shape.Pill, + ), + padding = indicatorPadding, + ), + padding = indicatorPadding, + ) +} + +private val indicatorPadding = Dimensions.of(3.dp) +private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f +private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f +private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f +private const val INDICATOR_SIZE_DP = 16f +private const val INDICATOR_REAR_COLOR_ALPHA = .24f \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt index b224a1838d..a01ceb3d22 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt @@ -2,9 +2,6 @@ package com.tangem.common.ui.charts.layer import android.content.res.Configuration import androidx.annotation.FloatRange -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,22 +17,22 @@ import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.fullWidth import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLineSpec +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLine import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState -import com.patrykandpatrick.vico.compose.common.shader.BrushShader +import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.core.common.shader.ColorShader import com.patrykandpatrick.vico.core.common.shader.DynamicShader import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal /** @@ -59,117 +56,50 @@ internal fun rememberMarketChartLayer( backgroundLineColor: Color, secondLineColor: Color, backgroundSecondLineColor: Color, - startDrawingAnimation: MutableState, axisValueOverrider: AxisValueOverrider, secondColorOnTheRightSide: Boolean, @FloatRange(from = 0.0, to = 1.0) markerFraction: Float?, canvasHeight: Int, ): LineCartesianLayer { - var animationFraction: Float? by remember { mutableStateOf(null) } + val backgroundColorLineGradient = persistentListOf(backgroundLineColor, Color.Transparent) + val backgroundSecondLineColorGradient = persistentListOf(backgroundSecondLineColor, Color.Transparent) - LaunchedEffect(startDrawingAnimation.value) { - animationFraction = null - if (startDrawingAnimation.value) { - animate( - initialValue = 0f, - targetValue = 1f, - animationSpec = tween(easing = LinearEasing, durationMillis = 1000), - ) { start, _ -> - if (start == 1f) { - animationFraction = null - startDrawingAnimation.value = false - } else { - animationFraction = start - } - } - } - } + val markerSet = markerFraction != null - return rememberRawMarketChartLayer( - lineColor = lineColor, - backgroundLineColor = backgroundLineColor, - secondLineColor = secondLineColor, - backgroundSecondLineColor = backgroundSecondLineColor, + return rememberLayer( + fractionValue = markerFraction ?: 0f, axisValueOverrider = axisValueOverrider, - secondColorOnTheRightSide = secondColorOnTheRightSide, - markerFraction = markerFraction, - animationFraction = animationFraction, canvasHeight = canvasHeight, + lineColor = if (markerFraction != null) { + secondLineColor + } else { + lineColor + }, + backLineColor = if (markerSet && !secondColorOnTheRightSide) { + backgroundSecondLineColorGradient + } else { + backgroundColorLineGradient + }, + lineColorRight = when { + markerSet && secondColorOnTheRightSide -> secondLineColor + else -> lineColor + }, + backLineColorRight = when { + markerSet && secondColorOnTheRightSide -> backgroundSecondLineColorGradient + else -> backgroundColorLineGradient + }, ) } @Suppress("LongParameterList") -@Composable -private fun rememberRawMarketChartLayer( - lineColor: Color, - backgroundLineColor: Color, - secondLineColor: Color, - backgroundSecondLineColor: Color, - axisValueOverrider: AxisValueOverrider, - canvasHeight: Int, - secondColorOnTheRightSide: Boolean = false, - @FloatRange(from = 0.0, to = 1.0) markerFraction: Float? = null, - @FloatRange(from = 0.0, to = 1.0) animationFraction: Float? = null, -): LineCartesianLayer { - val backgroundColorLineGradient = listOf(backgroundLineColor, Color.Transparent) - val backgroundSecondLineColorGradient = listOf(backgroundSecondLineColor, Color.Transparent) - - val markerSet = markerFraction != null - val animationRunning = animationFraction != null && animationFraction != 1f - - val layerColors = when { - !animationRunning && markerSet && secondColorOnTheRightSide -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - lineColorRight = secondLineColor, - backLineColorRight = backgroundSecondLineColorGradient, - ) - } - !animationRunning && markerSet && !secondColorOnTheRightSide -> { - LayerColors( - lineColor = secondLineColor, - backLineColor = backgroundSecondLineColorGradient, - lineColorRight = lineColor, - backLineColorRight = backgroundColorLineGradient, - ) - } - animationRunning -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - lineColorRight = Color.Transparent, - backLineColorRight = listOf(Color.Transparent, Color.Transparent), - ) - } - else -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - ) - } - } - - return rememberLayer( - fractionValue = animationFraction ?: markerFraction, - axisValueOverrider = axisValueOverrider, - layerColors = layerColors, - canvasHeight = canvasHeight, - ) -} - -private data class LayerColors( - val lineColor: Color, - val backLineColor: List, - val lineColorRight: Color? = null, - val backLineColorRight: List? = null, -) - @Composable private fun rememberLayer( - fractionValue: Float?, + fractionValue: Float, axisValueOverrider: AxisValueOverrider, - layerColors: LayerColors, + lineColor: Color, + backLineColor: ImmutableList, + lineColorRight: Color, + backLineColorRight: ImmutableList, canvasHeight: Int, ): LineCartesianLayer { val endGradientColorPosition = if (canvasHeight != 0) { @@ -178,47 +108,27 @@ private fun rememberLayer( Float.POSITIVE_INFINITY } + val alineColor = remember(lineColor) { lineColor.toArgb() } + val alineColorRight = remember(lineColorRight) { lineColorRight.toArgb() } + return rememberLineCartesianLayer( - listOf( - if (layerColors.lineColorRight == null || layerColors.backLineColorRight == null || fractionValue == null) { - rememberLineSpec( - shader = remember(layerColors.lineColor) { ColorShader(color = layerColors.lineColor.toArgb()) }, - backgroundShader = remember(layerColors.backLineColor, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColor, - endY = endGradientColorPosition, - ), - ) - }, - ) - } else { - rememberSplitLineSpec( - shader = remember(layerColors.lineColor, layerColors.lineColorRight, fractionValue) { - DynamicShader.Companion.horizontalGradient( - colors = intArrayOf(layerColors.lineColor.toArgb(), layerColors.lineColorRight.toArgb()), - positions = floatArrayOf(fractionValue, fractionValue), - ) - }, - backgroundShaderFirst = remember(layerColors.backLineColor, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColor, - endY = endGradientColorPosition, - ), - ) - }, - backgroundShaderSecond = remember(layerColors.backLineColorRight, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColorRight, - endY = endGradientColorPosition, - ), - ) - }, - xSplitFraction = fractionValue, - ) - }, + LineCartesianLayer.LineProvider.series( + rememberSplitLine( + shader = DynamicShader.Companion.horizontalGradient( + colors = intArrayOf(alineColor, alineColorRight), + positions = floatArrayOf(fractionValue, fractionValue), + ), + backgroundShaderFirst = Brush.verticalGradient( + colors = backLineColor, + endY = endGradientColorPosition, + ).toDynamicShader(), + backgroundShaderSecond = Brush.verticalGradient( + colors = backLineColorRight, + endY = endGradientColorPosition, + ).toDynamicShader(), + xSplitFraction = fractionValue, + thickness = 1.dp, + ), ), axisValueOverrider = axisValueOverrider, ) @@ -250,25 +160,26 @@ private fun LayerChartPreview( CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f), secondColorOnTheRightSide = true, axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), + markerFraction = 0.35f, canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, @@ -278,16 +189,16 @@ private fun LayerChartPreview( axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, @@ -297,29 +208,9 @@ private fun LayerChartPreview( axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - model = model, - ) - - CartesianChartHost( - modifier = Modifier.fillMaxWidth(), - chart = rememberCartesianChart( - rememberRawMarketChartLayer( - lineColor = lineColor, - backgroundLineColor = lineColor.copy(alpha = 0.24f), - secondLineColor = lineColor, - backgroundSecondLineColor = lineColor.copy(alpha = 0.24f), - markerFraction = 0.35f, - secondColorOnTheRightSide = true, - animationFraction = 0.7f, - axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), - canvasHeight = 495, - ), - ), - zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) } diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt new file mode 100644 index 0000000000..034af69926 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt @@ -0,0 +1,56 @@ +package com.tangem.common.ui.charts.layer + +import com.patrykandpatrick.vico.core.cartesian.CartesianDrawContext +import com.patrykandpatrick.vico.core.cartesian.CartesianMeasureContext +import com.patrykandpatrick.vico.core.cartesian.HorizontalDimensions +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.data.ChartValues + +@Suppress("MagicNumber") +class TimeItemPlacer : HorizontalAxis.ItemPlacer { + + private val ChartValues.measuredLabelValues + get() = buildList { + // produce exactly 6 values distributed evenly + val xLength = maxX - minX + val xStep = xLength / 7 + + repeat(times = 6) { + add(minX + xStep * (it + 1)) + } + } + + override fun getEndHorizontalAxisInset( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + tickThickness: Float, + maxLabelWidth: Float, + ): Float = 0f + + override fun getStartHorizontalAxisInset( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + tickThickness: Float, + maxLabelWidth: Float, + ): Float = 0f + + override fun getHeightMeasurementLabelValues( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + fullXRange: ClosedFloatingPointRange, + maxLabelWidth: Float, + ): List = context.chartValues.measuredLabelValues + + override fun getLabelValues( + context: CartesianDrawContext, + visibleXRange: ClosedFloatingPointRange, + fullXRange: ClosedFloatingPointRange, + maxLabelWidth: Float, + ): List = context.chartValues.measuredLabelValues + + override fun getWidthMeasurementLabelValues( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + fullXRange: ClosedFloatingPointRange, + ): List = context.chartValues.measuredLabelValues +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt deleted file mode 100644 index e27ce63a70..0000000000 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.tangem.common.ui.charts.marker - -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.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost -import com.patrykandpatrick.vico.compose.cartesian.fullWidth -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart -import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState -import com.patrykandpatrick.vico.compose.common.component.rememberLayeredComponent -import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent -import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent -import com.patrykandpatrick.vico.compose.common.of -import com.patrykandpatrick.vico.compose.common.shader.color -import com.patrykandpatrick.vico.compose.common.shape.dashed -import com.patrykandpatrick.vico.core.cartesian.* -import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider -import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel -import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker -import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker -import com.patrykandpatrick.vico.core.common.Dimensions -import com.patrykandpatrick.vico.core.common.component.TextComponent -import com.patrykandpatrick.vico.core.common.shader.DynamicShader -import com.patrykandpatrick.vico.core.common.shape.Shape -import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import java.math.BigDecimal - -/** - * @param color The color of the indicator and guideline. - * @param innerCircleColor The color of the inner circle of the indicator. - * - * @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect. - */ -@Composable -internal fun rememberTangemChartMarker(color: Color, innerCircleColor: Color): CartesianMarker { - val indicatorFrontComponent = rememberShapeComponent( - shape = Shape.Pill, - color = innerCircleColor, - ) - val indicatorCenterComponent = rememberShapeComponent( - shape = Shape.Pill, - color = color, - ) - val indicatorRearComponent = rememberShapeComponent( - shape = Shape.Pill, - color = if (color == Color.Transparent) { - Color.Transparent - } else { - color.copy(alpha = INDICATOR_REAR_COLOR_ALPHA) - }, - ) - val indicator = rememberLayeredComponent( - rear = indicatorRearComponent, - front = rememberLayeredComponent( - rear = indicatorCenterComponent, - front = indicatorFrontComponent, - padding = indicatorPadding, - ), - padding = indicatorPadding, - ) - val guideline = rememberUnboundedLineComponent( - color = color, - verticalAddDrawSpace = TangemTheme.dimens.spacing24, - shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) }, - ) - return remember(indicator, guideline) { - object : DefaultCartesianMarker( - label = TextComponent.build { textSizeSp = 0f }, - indicator = indicator, - indicatorSizeDp = INDICATOR_SIZE_DP, - guideline = guideline, - ) { - override fun getInsets( - context: CartesianMeasureContext, - outInsets: Insets, - horizontalDimensions: HorizontalDimensions, - ) { - with(context) { - super.getInsets(context, outInsets, horizontalDimensions) - val baseShadowInsetDp = - CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP - outInsets.top += (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels - outInsets.bottom += (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels - } - } - } - } -} - -private val indicatorPadding = Dimensions.of(3.dp) -private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f -private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f -private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f -private const val INDICATOR_SIZE_DP = 16f -private const val INDICATOR_REAR_COLOR_ALPHA = .24f - -// region Preview - -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemChartMarkerPreview( - @PreviewParameter(MarketChartPreviewDataProvider::class) previewData: Pair, List>, -) { - val marker = rememberTangemChartMarker(Color.Red, Color.White) - val y = previewData.second.map { it.toFloat() } - val x = List(y.size) { it.toFloat() } - val model = CartesianChartModel(LineCartesianLayerModel.build { series(x, y) }) - - val centerAprx = (model.models[0].minX + model.models[0].maxX) / 2f - val center = model.models[0].getXDeltaGcd().let { centerAprx - centerAprx % it } - - TangemThemePreview { - Box(modifier = Modifier.background(TangemTheme.colors.background.primary)) { - CartesianChartHost( - modifier = Modifier.fillMaxWidth(), - chart = rememberCartesianChart( - rememberLineCartesianLayer( - listOf(rememberLineSpec(shader = DynamicShader.color(Color.Blue))), - axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), - ), - persistentMarkers = mapOf(center to marker), - ), - model = model, - marker = marker, - zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - ) - } - } -} - -// endregion Preview \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt index 7e4887fedc..44f973dc73 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt @@ -1,6 +1,8 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal @Immutable @@ -30,7 +32,7 @@ sealed interface MarketChartData { */ @Immutable data class Data( - val x: List = listOf(), - val y: List = listOf(), + val x: ImmutableList = persistentListOf(), + val y: ImmutableList = persistentListOf(), ) : MarketChartData } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt index bcad7d3ceb..86f5ef5dd6 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt @@ -3,13 +3,13 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Stable import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.common.data.ExtraStore -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import com.tangem.common.ui.charts.state.converter.PointValuesConverter +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.formatter.FormatterWrapWithCache +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.withContext -import java.math.BigDecimal +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * This class represents a transaction for updating the state and look of a Market Chart. @@ -25,7 +25,12 @@ class Transaction( var chartData: MarketChartData.NoData? = null fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) { - chartLook = block(currentLook) + val newLook = block(currentLook) + + chartLook = newLook.copy( + xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter), + yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter), + ) } fun updateState(block: (prev: MarketChartData) -> MarketChartData.NoData) { @@ -56,7 +61,12 @@ class TransactionSuspend( } fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) { - chartLook = block(currentLook) + val newLook = block(currentLook) + + chartLook = newLook.copy( + xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter), + yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter), + ) } internal fun updateState(block: (prev: MarketChartData) -> MarketChartData) { @@ -75,21 +85,24 @@ class TransactionSuspend( class MarketChartDataProducer private constructor( initialData: MarketChartData, initialLook: MarketChartLook, - val pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + val pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), private val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - internal val startDrawingAnimation = MutableSharedFlow() internal val dataState = MutableStateFlow(initialData) internal val lookState = MutableStateFlow(initialLook) internal val entries = MutableStateFlow>(emptyList()) - - internal val modelProducer = CartesianChartModelProducer.build(dispatcher = dispatcher) + internal val modelProducer = CartesianChartModelProducer(dispatcher = dispatcher) + internal val rawData = MutableStateFlow(null) + private val mutex = Mutex() /** * This function runs a suspending transaction block to update the state and look of the Market Chart. */ - suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = - handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block)) + suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = withContext(dispatcher) { + mutex.withLock { + handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block)) + } + } /** * This function runs a non-suspending transaction block to update the state and look of the Market Chart. @@ -102,32 +115,30 @@ class MarketChartDataProducer private constructor( val chartData = transaction.chartData val oldData = dataState.value - if (chartData != null) { - dataState.value = chartData - } - if (chartData is MarketChartData.Data && (oldData !is MarketChartData.Data || oldData != chartData)) { - if (lookState.value.animationOnDataChange) { - startDrawingAnimation.emit(Unit) - } - withContext(dispatcher) { - val rawData = pointsValuesConverter.convert(chartData) + (lookState.value.xAxisFormatter as? FormatterWrapWithCache)?.clearCache() + (lookState.value.yAxisFormatter as? FormatterWrapWithCache)?.clearCache() - val entriesLocal = - rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) } + val rawData = pointsValuesConverter.convert(chartData) - entries.value = entriesLocal + val entriesLocal = + rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) } + currentCoroutineContext().ensureActive() + + runCatching { modelProducer.runTransaction { add(LineCartesianLayerModel.Partial(series = listOf(entriesLocal))) - - updateExtras { - it[entriesKey] = entriesLocal - it[xKey] = chartData.x - it[yKey] = chartData.y - } - }.await() + } } + + entries.value = entriesLocal + dataState.value = chartData + this.rawData.value = rawData + + delay(timeMillis = 200) + } else if (chartData != null) { + dataState.value = chartData } nonSuspendTransaction?.let { handleTransaction(it) } @@ -143,10 +154,6 @@ class MarketChartDataProducer private constructor( } companion object { - internal val entriesKey = ExtraStore.Key>() - internal val xKey = ExtraStore.Key>() - internal val yKey = ExtraStore.Key>() - private val initialData: MarketChartData = MarketChartData.NoData.Empty private val initialLook: MarketChartLook = MarketChartLook() @@ -159,7 +166,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ suspend fun buildSuspend( - pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), dispatcher: CoroutineDispatcher = Dispatchers.Default, block: TransactionSuspend.() -> Unit, ): MarketChartDataProducer { @@ -184,7 +191,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ fun build( - pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), dispatcher: CoroutineDispatcher = Dispatchers.Default, block: Transaction.() -> Unit, ): MarketChartDataProducer { diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt index c9ecaae2c0..72c608da53 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt @@ -1,5 +1,8 @@ package com.tangem.common.ui.charts.state +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.formatter.AxisLabelFormatter + /** * This class represents the look and feel of a Market Chart. * It includes properties for type, marker highlight, animation on data change, animate data appearance, @@ -7,16 +10,13 @@ package com.tangem.common.ui.charts.state * * @property type The type of the chart, can be either Growing or Falling. * @property markerHighlightRightSide A boolean indicating whether the marker highlights the right side of the chart. - * @property animationOnDataChange A boolean indicating whether to animate on data change. - * @property animateDataAppearance A boolean indicating whether to animate data appearance. * @property xAxisFormatter A formatter for the x-axis labels. * @property yAxisFormatter A formatter for the y-axis labels. */ +@Immutable data class MarketChartLook( val type: Type = Type.Growing, val markerHighlightRightSide: Boolean = true, - val animationOnDataChange: Boolean = false, - val animateDataAppearance: Boolean = false, val xAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() }, val yAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() }, ) { @@ -24,5 +24,6 @@ data class MarketChartLook( enum class Type { Growing, Falling, + Neutral, } } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt index a1b7e91f2c..df6d124201 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt @@ -1,9 +1,20 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +/** + * This class represents raw data for a Market Chart. Used for drawing the chart. + * + * @property originalIndexes If the source data has the original representation (due to reduced sampling), + * this list contains the original indexes of the data points. + * @property y The list of y-values. + * @property x The list of x-values. + */ @Immutable data class MarketChartRawData( - val y: List, - val x: List = List(y.size) { 1f }, + val originalIndexes: ImmutableList? = null, + val y: ImmutableList, + val x: ImmutableList = List(y.size) { 1.0 }.toImmutableList(), ) \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt index 92a0379f5d..e031fc1a27 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt @@ -2,7 +2,6 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener @@ -20,26 +19,23 @@ import java.math.BigDecimal @Composable fun rememberMarketChartState( dataProducer: MarketChartDataProducer = remember { MarketChartDataProducer.build {} }, - colorMapper: (MarketChartLook.Type) -> Color = { - when (it) { - MarketChartLook.Type.Growing -> Color.Green - MarketChartLook.Type.Falling -> Color.Red + colorMapper: (MarketChartLook.Type) -> Color = remember { + { + when (it) { + MarketChartLook.Type.Growing -> Color.Green + MarketChartLook.Type.Falling -> Color.Red + MarketChartLook.Type.Neutral -> Color.Gray + } } }, onMarkerShown: (x: BigDecimal?, y: BigDecimal?) -> Unit = { _, _ -> }, ): MarketChartState { - val lookState = dataProducer.lookState.collectAsStateWithLifecycle() + val lookState = dataProducer.lookState.collectAsState() val state = remember(dataProducer, lookState, colorMapper, onMarkerShown) { MarketChartState(dataProducer, lookState, colorMapper, onMarkerShown) } - LaunchedEffect(Unit) { - dataProducer.startDrawingAnimation.collect { - state.startDrawingAnimation() - } - } - return state } @@ -59,7 +55,6 @@ class MarketChartState internal constructor( private val colorMapper: (MarketChartLook.Type) -> Color, private val markerCallback: (x: BigDecimal?, y: BigDecimal?) -> Unit, ) { - internal val startDrawingAnimationState = mutableStateOf(false) internal val modelProducer = dataProducer.modelProducer internal val chartColor by derivedStateOf { @@ -70,29 +65,29 @@ class MarketChartState internal constructor( lookState.value.markerHighlightRightSide } - internal val xValueFormatter by derivedStateOf { - CartesianValueFormatter { value, _, _ -> - val state = dataProducer.dataState.value as? MarketChartData.Data - ?: return@CartesianValueFormatter value.toString() + internal val xValueFormatter = CartesianValueFormatter { value, _, _ -> + val formatter = dataProducer.lookState.value.xAxisFormatter - lookState.value.xAxisFormatter.format( - value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state), - ) - } + val state = dataProducer.dataState.value as? MarketChartData.Data + ?: return@CartesianValueFormatter value.toString() + + formatter.format( + value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state), + ) } - internal val yValueFormatter by derivedStateOf { - CartesianValueFormatter { value, _, _ -> - val state = dataProducer.dataState.value as? MarketChartData.Data - ?: return@CartesianValueFormatter value.toString() + internal val yValueFormatter = CartesianValueFormatter { value, _, _ -> + val formatter = dataProducer.lookState.value.yAxisFormatter - lookState.value.yAxisFormatter.format( - value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state), - ) - } + val state = dataProducer.dataState.value as? MarketChartData.Data + ?: return@CartesianValueFormatter value.toString() + + formatter.format( + value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state), + ) } - internal var markerFraction: Float? by mutableStateOf(null) + internal var markerFraction by mutableStateOf(null) internal val markerVisibilityListener = object : CartesianMarkerVisibilityListener { override fun onShown(marker: CartesianMarker, targets: List) { @@ -116,24 +111,17 @@ class MarketChartState internal constructor( } } - val isDrawingAnimationInProgress: Boolean by derivedStateOf { - startDrawingAnimationState.value - } - private fun getPoint(targets: List): Pair? { val entry = (targets[0] as LineCartesianLayerMarkerTarget).points[0].entry val entryIndex = dataProducer.entries.value.indexOf(entry).takeIf { it != -1 } ?: return null val state = dataProducer.dataState.value as? MarketChartData.Data ?: return null - val x = state.x.getOrNull(entryIndex) ?: return null - val y = state.y.getOrNull(entryIndex) ?: return null + val rawData = dataProducer.rawData.value ?: return null + + val originalIndex = rawData.originalIndexes?.getOrNull(entryIndex) + val index = originalIndex ?: entryIndex + + val x = state.x.getOrNull(index) ?: return null + val y = state.y.getOrNull(index) ?: return null return x to y } - - fun startDrawingAnimation() { - startDrawingAnimationState.value = true - } - - fun stopDrawingAnimation() { - startDrawingAnimationState.value = false - } } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt deleted file mode 100644 index 1dab1cd0cc..0000000000 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.tangem.common.ui.charts.state - -import java.math.BigDecimal - -/** - * Interface to convert chart data values to Floats and backwards. - * - * We need to convert the values on the graph to floating point values in order to display them correctly on the canvas. - * We also need to determine exactly which floating point value on the graph corresponds to the decimal point, - * so that we can format the actual value and display on the x/y axis. - */ -interface PointValuesConverter { - - fun convert(data: MarketChartData.Data): MarketChartRawData - - fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal - - fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal -} - -object DefaultPointValuesConverter : PointValuesConverter { - - override fun convert(data: MarketChartData.Data): MarketChartRawData { - val minX = data.x.min() - val minY = data.y.min() - - val normY = data.y.map { normalize(it, minY) } - val normX = data.x.map { normalize(it, minX) } - - return MarketChartRawData( - x = normX, - y = normY, - ) - } - - override fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal { - val dataMin = data.x.min() - val scale = dataMin.scale() - val bVal = if (scale > 2) { - rawX.toBigDecimal().movePointLeft(scale - 2) + dataMin - } else { - rawX.toBigDecimal() + dataMin - } - - return bVal - } - - override fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal { - val dataMin = data.y.min() - val scale = dataMin.scale() - val bVal = if (scale > 2) { - rawY.toBigDecimal().movePointLeft(scale - 2) + dataMin - } else { - rawY.toBigDecimal() + dataMin - } - - return bVal - } - - // TODO enhance algorithm for values with big difference between min and max, which cannot fit in Float - private fun normalize(value: BigDecimal, min: BigDecimal, scale: Int = min.scale()): Float { - val n = value - min - return if (scale > 2) { - n.movePointRight(scale - 2).toFloat() - } else { - n.toFloat() - } - } -} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt new file mode 100644 index 0000000000..107eef4c68 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt @@ -0,0 +1,23 @@ +package com.tangem.common.ui.charts.state.converter + +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import java.math.BigDecimal + +/** + * Interface to convert chart data values to Floats and backwards. + * + * We need to convert the values on the graph to floating point values in order to display them correctly on the canvas. + * We also need to determine exactly which floating point value on the graph corresponds to the decimal point, + * so that we can format the actual value and display on the x/y axis. + * + * **[prepareRawXForFormat] and [prepareRawYForFormat] must be very fast because they are called in the onDraw method** + */ +interface PointValuesConverter { + + fun convert(data: MarketChartData.Data): MarketChartRawData + + fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal + + fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt new file mode 100644 index 0000000000..e51a8f72a6 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt @@ -0,0 +1,108 @@ +package com.tangem.common.ui.charts.state.converter + +import com.tangem.common.ui.charts.downsample.LTThreeBuckets +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +@Suppress("MagicNumber") +class PriceAndTimePointValuesConverter( + private val needToFormatAxis: Boolean, +) : PointValuesConverter { + + private data class MinMaxCache( + val minX: BigDecimal, + val maxX: BigDecimal, + val minY: BigDecimal, + val maxY: BigDecimal, + ) + + private var minMaxCache = MinMaxCache(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO) + private val formatYValuesCache = mutableMapOf() + private val formatXValuesCache = mutableMapOf() + + override fun convert(data: MarketChartData.Data): MarketChartRawData { + formatYValuesCache.clear() + formatXValuesCache.clear() + val cache = MinMaxCache( + minY = data.y.minOrNull() ?: BigDecimal.ZERO, + maxY = data.y.maxOrNull() ?: BigDecimal.ZERO, + minX = data.x.minOrNull() ?: BigDecimal.ZERO, + maxX = data.x.maxOrNull() ?: BigDecimal.ZERO, + ) + minMaxCache = cache + + val normY = data.y.normalizeToDouble(min = cache.minY, max = cache.maxY) + val normX = data.x.normalizeTime(min = cache.minX, max = cache.maxX) + + return if (normX.size > MAX_POINTS) { + LTThreeBuckets + .downsample(normX, normY, MAX_POINTS - 2) + .let { + MarketChartRawData( + originalIndexes = it.originalIndexes.toImmutableList(), + x = it.x.toImmutableList(), + y = it.y.toImmutableList(), + ) + } + } else { + MarketChartRawData( + x = normX.toImmutableList(), + y = normY.toImmutableList(), + ) + } + } + + override fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal { + if (!needToFormatAxis) return BigDecimal.ZERO + if (formatXValuesCache.containsKey(rawX)) return formatXValuesCache[rawX]!! + + val result = (rawX * MINUTE).toBigDecimal() + + formatXValuesCache[rawX] = result + return result + } + + override fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal { + if (!needToFormatAxis) return BigDecimal.ZERO + if (formatYValuesCache.containsKey(rawY)) return formatYValuesCache[rawY]!! + + val min = minMaxCache.minY + val max = minMaxCache.maxY + val length = max - min + + val result = when { + rawY < 0.01f -> min + rawY < 0.55f && rawY > 0.45f -> min + length / 2.toBigDecimal() + rawY > 0.97f && rawY < 1.01f -> max + else -> length * rawY.toBigDecimal() + min + } + formatYValuesCache[rawY] = result + return result + } + + private fun List.normalizeToDouble(min: BigDecimal, max: BigDecimal): List { + if (min == max) { + return List(size) { 0.5 } + } + + return map { ((it - min) / (max - min)).toDouble() } + } + + private fun List.normalizeTime(min: BigDecimal, max: BigDecimal): List { + if (min == max) { + return List(size) { 0.5 } + } + + return map { + (it / MINUTE_BIG).toDouble() + } + } + + private companion object { + private const val MAX_POINTS = 502 + private const val MINUTE = 60000L + private val MINUTE_BIG = 60000L.toBigDecimal() + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt similarity index 76% rename from common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt rename to common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt index e01681994c..46feae32c2 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt @@ -1,4 +1,4 @@ -package com.tangem.common.ui.charts.state +package com.tangem.common.ui.charts.state.formatter import androidx.compose.runtime.Stable import java.math.BigDecimal @@ -7,6 +7,8 @@ import java.math.BigDecimal * Used for formatting the axis labels in a chart. * It takes a BigDecimal value and returns a CharSequence that represents the formatted label. * + * [format] has to be very fast because it is called in the onDraw method. + * * @param value The value to be formatted. * @return The formatted label as a CharSequence. */ diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt new file mode 100644 index 0000000000..b0a0fbe8bc --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt @@ -0,0 +1,15 @@ +package com.tangem.common.ui.charts.state.formatter + +import java.math.BigDecimal + +internal class FormatterWrapWithCache(private val formatter: AxisLabelFormatter) : AxisLabelFormatter { + private val cache = mutableMapOf() + + override fun format(value: BigDecimal): CharSequence { + return cache.getOrPut(value) { formatter.format(value) } + } + + fun clearCache() { + cache.clear() + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt index 78ce58f37d..9597b3d764 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt @@ -2,10 +2,12 @@ package com.tangem.common.ui.amountScreen.converters import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.checkExceedBalance import com.tangem.common.ui.amountScreen.utils.getFiatValue import com.tangem.common.ui.amountScreen.utils.getKeyboardAction +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.utils.isNullOrZero @@ -51,6 +53,7 @@ class AmountReduceByTransformer( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, + error = resourceReference(R.string.send_validation_amount_exceeds_balance), cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), keyboardOptions = KeyboardOptions( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt index aed8332b0b..c81634b818 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt @@ -2,10 +2,12 @@ package com.tangem.common.ui.amountScreen.converters import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.checkExceedBalance import com.tangem.common.ui.amountScreen.utils.getFiatValue import com.tangem.common.ui.amountScreen.utils.getKeyboardAction +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.utils.isNullOrZero @@ -45,6 +47,7 @@ class AmountReduceToTransformer( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, + error = resourceReference(R.string.send_validation_amount_exceeds_balance), cryptoAmount = amountTextField.cryptoAmount.copy(value = value), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), keyboardOptions = KeyboardOptions( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index ad251378f6..410a23b468 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -54,7 +54,7 @@ class AmountStateConverter( return AmountState.Data( walletName = userWallet.name, - walletBalance = resourceReference(R.string.send_wallet_balance_format, wrappedList(crypto, fiat)), + walletBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)), tokenIconState = iconStateConverter.convert(status), amountTextField = amountFieldConverter.convert(value), isPrimaryButtonEnabled = false, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt index f0be223891..d45e7c07bd 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt @@ -2,11 +2,13 @@ package com.tangem.common.ui.amountScreen.converters.field import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.checkExceedBalance import com.tangem.common.ui.amountScreen.utils.getCryptoValue import com.tangem.common.ui.amountScreen.utils.getFiatValue import com.tangem.common.ui.amountScreen.utils.getKeyboardAction +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.utils.isNullOrZero @@ -59,6 +61,7 @@ class AmountFieldChangeTransformer( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, + error = resourceReference(R.string.send_validation_amount_exceeds_balance), cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), keyboardOptions = KeyboardOptions( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt index 1144bf4bcd..f190cea1db 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt @@ -1,16 +1,16 @@ package com.tangem.common.ui.amountScreen.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.BottomCenter +import androidx.compose.ui.Alignment.Companion.TopCenter import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -80,6 +80,8 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri val secondaryAmount = if (amountField.isFiatValue) amountField.cryptoAmount else amountField.fiatAmount Box( modifier = Modifier + .fillMaxWidth() + .animateContentSize() .padding( top = TangemTheme.dimens.spacing8, start = TangemTheme.dimens.spacing12, @@ -105,7 +107,7 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri color = TangemTheme.colors.text.tertiary, textAlign = TextAlign.Center, modifier = Modifier - .align(BottomCenter) + .align(TopCenter) .padding(bottom = TangemTheme.dimens.spacing32), ) AmountFieldError( @@ -113,7 +115,10 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri error = amountField.error, modifier = Modifier .align(BottomCenter) - .padding(bottom = TangemTheme.dimens.spacing12), + .padding( + top = TangemTheme.dimens.spacing20, + bottom = TangemTheme.dimens.spacing12, + ), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt index 67c6ed8cc9..c578193df0 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt @@ -1,30 +1,12 @@ package com.tangem.common.ui.amountScreen.utils -import com.tangem.blockchain.common.Amount import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.BigDecimalFormatter.EMPTY_BALANCE_SIGN import com.tangem.domain.appcurrency.model.AppCurrency import java.math.BigDecimal -private const val CRYPTO_FEE_DECIMALS = 6 - -fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): TextReference? { - if (amount == null) return null - return combinedReference( - if (isFeeApproximate) stringReference("${BigDecimalFormatter.CAN_BE_LOWER_SIGN} ") else TextReference.EMPTY, - stringReference( - BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = amount.value, - cryptoCurrency = amount.currencySymbol, - decimals = amount.decimals.coerceAtMost(CRYPTO_FEE_DECIMALS), - ), - ), - ) -} - fun getFiatReference(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): TextReference? { if (value == null || rate == null) return null val formattedFiat = getFiatString(value = value, rate = rate, appCurrency = appCurrency) diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt index ba65ad6d9c..73c62d311c 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt @@ -93,10 +93,12 @@ private fun GiveTxPermissionBottomSheetContent(content: GiveTxPermissionBottomSh PrimaryButtonIconEnd( text = stringResource(id = R.string.common_approve), iconResId = R.drawable.ic_tangem_24, + showProgress = data.approveButton.loading, modifier = Modifier .fillMaxWidth() .padding(horizontal = TangemTheme.dimens.spacing16), onClick = data.approveButton.onClick, + enabled = data.approveButton.enabled, ) SpacerH12() @@ -107,6 +109,7 @@ private fun GiveTxPermissionBottomSheetContent(content: GiveTxPermissionBottomSh .fillMaxWidth() .padding(horizontal = TangemTheme.dimens.spacing16), onClick = content.onCancel, + enabled = data.cancelButton.enabled, ) SpacerH16() @@ -151,7 +154,7 @@ private fun AmountItem( currency: String, approveType: ApproveType, approveItems: ImmutableList, - onChangeApproveType: (ApproveType) -> Unit, + onChangeApproveType: ((ApproveType) -> Unit)?, ) { var isExpandSelector by remember { mutableStateOf(false) } var amountSize by remember { mutableStateOf(IntSize.Zero) } @@ -161,6 +164,7 @@ private fun AmountItem( .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) .clickable( + enabled = onChangeApproveType != null, interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), onClick = { isExpandSelector = true }, @@ -187,34 +191,35 @@ private fun AmountItem( ) SpacerWMax() Text( - text = stringResource( - when (approveType) { - ApproveType.LIMITED -> R.string.give_permission_current_transaction - ApproveType.UNLIMITED -> R.string.give_permission_unlimited - }, - ), + text = approveType.text.resolveReference(), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body1, maxLines = 1, ) - Icon( - painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)), - contentDescription = null, - tint = TangemTheme.colors.icon.informative, - modifier = Modifier.padding(start = TangemTheme.dimens.spacing2), + if (onChangeApproveType != null) { + Icon( + painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing2), + ) + } + } + if (onChangeApproveType != null) { + DropdownSelector( + isExpanded = isExpandSelector, + onDismiss = { isExpandSelector = false }, + onItemClick = { approveType -> + onChangeApproveType.let { + isExpandSelector = false + onChangeApproveType.invoke(approveType) + } + }, + items = approveItems, + selectedType = approveType, + amountSize = amountSize, ) } - DropdownSelector( - isExpanded = isExpandSelector, - onDismiss = { isExpandSelector = false }, - onItemClick = { approveType -> - isExpandSelector = false - onChangeApproveType.invoke(approveType) - }, - items = approveItems, - selectedType = approveType, - amountSize = amountSize, - ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt index dbdce22ba9..9f52bca622 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt @@ -1,6 +1,8 @@ package com.tangem.common.ui.bottomsheet.permission.state +import com.tangem.common.ui.R import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -22,12 +24,13 @@ sealed class GiveTxPermissionState { val approveItems: ImmutableList = ApproveType.entries.toImmutableList(), val approveButton: ApprovePermissionButton, val cancelButton: CancelPermissionButton, - val onChangeApproveType: (ApproveType) -> Unit, + val onChangeApproveType: ((ApproveType) -> Unit)? = null, ) : GiveTxPermissionState() } -enum class ApproveType { - LIMITED, UNLIMITED +enum class ApproveType(val text: TextReference) { + LIMITED(resourceReference(R.string.give_permission_current_transaction)), + UNLIMITED(resourceReference(R.string.give_permission_unlimited)), } data class ApprovePermissionButton( diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt new file mode 100644 index 0000000000..8a9ccffd1e --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt @@ -0,0 +1,202 @@ +package com.tangem.common.ui.navigationButtons + +import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +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.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.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.navigationButtons.preview.NavigationButtonsPreview +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.TangemButtonsDefaults +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.rememberHapticFeedback +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun NavigationButtonsBlock(buttonState: NavigationButtonsState, modifier: Modifier = Modifier) { + val state = buttonState as? NavigationButtonsState.Data + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxWidth(), + ) { + ExtraButtons(state?.extraButtons, state?.txUrl) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + PreviousButton(state?.prevButton) + PrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f)) + } + + SecondaryButton(state?.secondaryButton) + } +} + +@Composable +private fun PrimaryButton(primaryButton: NavigationButton?, modifier: Modifier = Modifier) { + AnimatedContent( + targetState = primaryButton, + transitionSpec = { + val isPrimaryToHide = targetState != null && initialState == null + val isPrimaryWasVisible = targetState == null && initialState != null + if (isPrimaryToHide || isPrimaryWasVisible) { + slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()) + .togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut())) + } else { + fadeIn().togetherWith(fadeOut()) + } + }, + contentAlignment = Alignment.Center, + label = "Animate show primary button", + modifier = modifier.fillMaxWidth(), + ) { button -> + if (button != null && button.textReference != TextReference.EMPTY) { + val icon = if (button.iconRes != null && button.isIconVisible) { + TangemButtonIconPosition.End(iconResId = button.iconRes) + } else { + TangemButtonIconPosition.None + } + TangemButton( + text = button.textReference.resolveReference(), + enabled = button.isEnabled, + onClick = button.onClick, + showProgress = button.showProgress, + colors = TangemButtonsDefaults.primaryButtonColors, + icon = icon, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Spacer(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun SecondaryButton(secondaryButton: NavigationButton?) { + AnimatedContent( + targetState = secondaryButton, + transitionSpec = { + val isPrimaryToHide = targetState != null && initialState == null + val isPrimaryWasVisible = targetState == null && initialState != null + if (isPrimaryToHide || isPrimaryWasVisible) { + slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()) + .togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut())) + } else { + fadeIn().togetherWith(fadeOut()) + } + }, + contentAlignment = Alignment.Center, + label = "Animate show secondary button", + modifier = Modifier.fillMaxWidth(), + ) { button -> + if (button != null && button.textReference != TextReference.EMPTY) { + val icon = button.iconRes?.let { TangemButtonIconPosition.End(iconResId = it) } + ?: TangemButtonIconPosition.None + + TangemButton( + text = button.textReference.resolveReference(), + enabled = button.isEnabled, + onClick = button.onClick, + icon = icon, + showProgress = button.showProgress, + colors = TangemButtonsDefaults.secondaryButtonColors, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + ) + } else { + Spacer(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun PreviousButton(prevButton: NavigationButton?) { + AnimatedVisibility( + visible = prevButton != null, + enter = expandHorizontally(expandFrom = Alignment.End), + exit = shrinkHorizontally(shrinkTowards = Alignment.End), + label = "Animate show prev button", + ) { + val button = remember(this) { requireNotNull(prevButton) } + if (button.iconRes != null && button.isIconVisible) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(button.iconRes), + ), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) + .background(TangemTheme.colors.button.secondary) + .clickable(onClick = button.onClick) + .padding(TangemTheme.dimens.spacing12), + ) + } + } +} + +@Composable +private fun ExtraButtons(extraButtons: ImmutableList?, txUrl: String?) { + AnimatedVisibility( + visible = !txUrl.isNullOrBlank() && extraButtons != null, + enter = slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()), + exit = slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut()), + label = "Animate show sent state buttons", + modifier = Modifier.fillMaxWidth(), + ) { + val buttons = remember(this) { requireNotNull(extraButtons) } + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + ) { + buttons.forEach { button -> + val icon = button.iconRes?.let { TangemButtonIconPosition.Start(iconResId = it) } + ?: TangemButtonIconPosition.None + TangemButton( + text = button.textReference.resolveReference(), + icon = icon, + onClick = rememberHapticFeedback(state = button, onAction = button.onClick), + modifier = Modifier.weight(1f), + enabled = button.isEnabled, + showProgress = false, + colors = TangemButtonsDefaults.secondaryButtonColors, + ) + } + } + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NavigationButtonsBlock_Preview( + @PreviewParameter(NavigationButtonsBlockDataProvider::class) navigationButtonsState: NavigationButtonsState, +) { + TangemThemePreview { + NavigationButtonsBlock(navigationButtonsState) + } +} + +private class NavigationButtonsBlockDataProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(NavigationButtonsPreview.allButtons) +} + +// endregion \ 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 new file mode 100644 index 0000000000..c125a871d1 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt @@ -0,0 +1,27 @@ +package com.tangem.common.ui.navigationButtons + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +sealed class NavigationButtonsState { + data object Empty : NavigationButtonsState() + + data class Data( + val primaryButton: NavigationButton, + val prevButton: NavigationButton?, + val secondaryButton: NavigationButton?, + val extraButtons: ImmutableList, + val txUrl: String? = null, + ) : NavigationButtonsState() +} + +data class NavigationButton( + val textReference: TextReference, + @DrawableRes val iconRes: Int? = null, + val isSecondary: Boolean, + val isIconVisible: Boolean, + val showProgress: Boolean, + val isEnabled: Boolean, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt new file mode 100644 index 0000000000..738598de82 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt @@ -0,0 +1,67 @@ +package com.tangem.common.ui.navigationButtons.preview + +import com.tangem.common.ui.R +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import kotlinx.collections.immutable.persistentListOf + +internal object NavigationButtonsPreview { + + private val extraButtons = persistentListOf( + NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_tangem_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = {}, + ), + NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_tangem_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = {}, + ), + ) + + private val next = NavigationButton( + textReference = resourceReference(R.string.common_next), + isSecondary = false, + isIconVisible = false, + showProgress = false, + isEnabled = true, + onClick = {}, + ) + private val prev = NavigationButton( + textReference = TextReference.EMPTY, + iconRes = R.drawable.ic_back_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = {}, + ) + + private val finished = NavigationButton( + textReference = resourceReference(R.string.common_close), + isSecondary = false, + isIconVisible = false, + showProgress = false, + isEnabled = true, + onClick = {}, + ) + + val allButtons = NavigationButtonsState.Data( + primaryButton = finished, + prevButton = prev, + secondaryButton = next, + extraButtons = extraButtons, + txUrl = "https://tangem.com", + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchEnvironmentInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchEnvironmentInterceptor.kt new file mode 100644 index 0000000000..dbfbc5e54f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchEnvironmentInterceptor.kt @@ -0,0 +1,54 @@ +package com.tangem.datasource.api.common + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.utils.Provider +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okio.IOException + +/** + * Switch api environment [Interceptor] + * + * @property id api config id [ApiConfig.ID] + * @property apiConfigsManager api configs manager + * +[REDACTED_AUTHOR] + */ +internal class SwitchEnvironmentInterceptor( + private val id: ApiConfig.ID, + private val apiConfigsManager: ApiConfigsManager, +) : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + val builder = request.newBuilder() + + val environmentConfig = apiConfigsManager.getEnvironmentConfig(id) + + request = builder + .url(url = request.url.adjustBaseUrl(environmentConfig.baseUrl)) + .addHeaders(headers = environmentConfig.headers) + .build() + + return chain.proceed(request) + } + + private fun HttpUrl.adjustBaseUrl(url: String): HttpUrl { + return this.newBuilder() + .host(host = url.toHttpUrl().host) + .build() + } + + private fun Request.Builder.addHeaders(headers: Map>): Request.Builder { + headers.forEach { (name, valueProvider) -> + addHeader(name = name, value = valueProvider()) + } + + return this + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt index 2e6adf8fe7..f5627c6f96 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt @@ -3,9 +3,11 @@ package com.tangem.datasource.api.common.adapter import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.adapters.EnumJsonAdapter -import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO.BalanceTypeDTO import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO -import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardClaimingDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.RewardTypeDTO import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingTransactionStatusDTO @@ -24,13 +26,15 @@ object UnknownEnumMoshiAdapter { fun Moshi.Builder.addStakeKitEnumFallbackAdapters(): Moshi.Builder { val map = mapOf( + BalanceTypeDTO::class.java to BalanceTypeDTO.UNKNOWN, NetworkTypeDTO::class.java to NetworkTypeDTO.UNKNOWN, - StakingActionTypeDTO::class.java to StakingActionTypeDTO.UNKNOWN, - YieldDTO.RewardTypeDTO::class.java to YieldDTO.RewardTypeDTO.UNKNOWN, - BalanceDTO.BalanceType::class.java to BalanceDTO.BalanceType.UNKNOWN, - StakingTransactionTypeDTO::class.java to StakingTransactionTypeDTO.UNKNOWN, - StakingTransactionStatusDTO::class.java to StakingTransactionStatusDTO.UNKNOWN, + RewardClaimingDTO::class.java to RewardClaimingDTO.UNKNOWN, + RewardScheduleDTO::class.java to RewardScheduleDTO.UNKNOWN, + RewardTypeDTO::class.java to RewardTypeDTO.UNKNOWN, StakingActionStatusDTO::class.java to StakingActionStatusDTO.UNKNOWN, + StakingActionTypeDTO::class.java to StakingActionTypeDTO.UNKNOWN, + StakingTransactionStatusDTO::class.java to StakingTransactionStatusDTO.UNKNOWN, + StakingTransactionTypeDTO::class.java to StakingTransactionTypeDTO.UNKNOWN, ) return apply { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt new file mode 100644 index 0000000000..65bc7a1e66 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -0,0 +1,44 @@ +package com.tangem.datasource.api.common.config + +typealias ApiConfigs = Set<@JvmSuppressWildcards ApiConfig> + +/** + * Api config + * + * @see API configuration + * +[REDACTED_AUTHOR] + */ +sealed class ApiConfig { + + /** Default environment */ + abstract val defaultEnvironment: ApiEnvironment + + /** Available environments */ + abstract val environmentConfigs: List + + /** Unique id */ + val id: ID = initializeId() + + enum class ID { + Express, + TangemTech, + StakeKit, + } + + private fun initializeId(): ID { + return when (this) { + is Express -> ID.Express + is TangemTech -> ID.TangemTech + is StakeKit -> ID.StakeKit + } + } + + companion object { + internal const val DEBUG_BUILD_TYPE = "debug" + internal const val INTERNAL_BUILD_TYPE = "internal" + internal const val MOCKED_BUILD_TYPE = "mocked" + internal const val EXTERNAL_BUILD_TYPE = "external" + internal const val RELEASE_BUILD_TYPE = "release" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt new file mode 100644 index 0000000000..00945bf772 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.config + +/** + * Api environment + * +[REDACTED_AUTHOR] + */ +enum class ApiEnvironment { + DEV, STAGE, PROD +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironmentConfig.kt new file mode 100644 index 0000000000..4ab1342caf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironmentConfig.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.utils.Provider + +/** + * Api environment config + * + * @property environment environment + * @property baseUrl base url + * @property headers headers + * +[REDACTED_AUTHOR] + */ +data class ApiEnvironmentConfig( + val environment: ApiEnvironment, + val baseUrl: String, + val headers: Map> = emptyMap(), +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt new file mode 100644 index 0000000000..134d24a3ac --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -0,0 +1,81 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.utils.RequestHeader +import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.utils.Provider +import com.tangem.utils.version.AppVersionProvider + +/** + * Express [ApiConfig] + * + * @property configManager config manager + * @property expressAuthProvider express auth provider + * @property appVersionProvider app version provider + */ +internal class Express( + private val configManager: ConfigManager, + private val expressAuthProvider: ExpressAuthProvider, + private val appVersionProvider: AppVersionProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createDevEnvironment(), + createStageEnvironment(), + createProdEnvironment(), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "[REDACTED_ENV_URL]", + headers = createHeaders(isProd = false), + ) + + private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.STAGE, + baseUrl = "[REDACTED_ENV_URL]", + headers = createHeaders(isProd = false), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://express.tangem.com/v1/", + headers = createHeaders(isProd = true), + ) + + private fun createHeaders(isProd: Boolean) = buildMap { + put(key = "api-key", value = Provider { getApiKey(isProd) }) + put(key = "user-id", value = Provider(expressAuthProvider::getUserId)) + put(key = "session-id", value = Provider(expressAuthProvider::getSessionId)) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider).values) + } + + private fun getApiKey(isProd: Boolean): String { + return if (isProd) { + configManager.config.express + } else { + configManager.config.devExpress + } + ?.apiKey + ?: error("No express config provided") + } + + private companion object { + + fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + DEBUG_BUILD_TYPE -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + MOCKED_BUILD_TYPE, + -> ApiEnvironment.STAGE + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/StakeKit.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/StakeKit.kt new file mode 100644 index 0000000000..4370c06c92 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/StakeKit.kt @@ -0,0 +1,33 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.lib.auth.StakeKitAuthProvider +import com.tangem.utils.Provider + +/** + * StakeKit [ApiConfig] + * + * @property stakeKitAuthProvider StakeKit auth provider + * +[REDACTED_AUTHOR] + */ +internal class StakeKit( + private val stakeKitAuthProvider: StakeKitAuthProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD + + override val environmentConfigs: List = listOf( + createProdEnvironment(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig { + return ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.stakek.it/v1/", + headers = mapOf( + "X-API-KEY" to Provider(stakeKitAuthProvider::getApiKey), + "accept" to Provider { "application/json" }, + ), + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt new file mode 100644 index 0000000000..c93ce9af1e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt @@ -0,0 +1,33 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.utils.RequestHeader +import com.tangem.utils.version.AppVersionProvider + +/** TangemTech [ApiConfig] */ +internal class TangemTech( + private val appVersionProvider: AppVersionProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD + + override val environmentConfigs = listOf( + createDevEnvironment(), + createProdEnvironment(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.tangem-tech.com/v1/", + headers = createHeaders(), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "https://devapi.tangem-tech.com/v1/", + headers = createHeaders(), + ) + + private fun createHeaders() = buildMap { + putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider).values) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt new file mode 100644 index 0000000000..880a57f286 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironmentConfig + +/** + * Api configs manager + * +[REDACTED_AUTHOR] + */ +interface ApiConfigsManager { + + /** Initialize resources */ + suspend fun initialize() {} + + /** Get environment config by [id] */ + fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt new file mode 100644 index 0000000000..4f4efdd955 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt @@ -0,0 +1,70 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiConfigs +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.ApiEnvironmentConfig +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* + +/** + * Implementation of [ApiConfigsManager] in DEV environment + * + * @param apiConfigs api configs + * @property appPreferencesStore app preferences store + * @property dispatchers coroutine dispatcher provider + */ +internal class DevApiConfigsManager( + apiConfigs: ApiConfigs, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : MutableApiConfigsManager { + + override val configs: Flow> get() = _apiConfigs + + private val _apiConfigs = MutableStateFlow(value = apiConfigs.associateWith { it.defaultEnvironment }) + + override suspend fun initialize() { + // We can't use appPreferencesStore.getObjectMap as base flow, + // because we should keep possibility to work with configs synchronous. + // See [getBaseUrl] + appPreferencesStore.getObjectMap(PreferencesKeys.apiConfigsEnvironmentKey) + .onEach { savedEnvironments -> + _apiConfigs.update { apiConfigs -> + apiConfigs.mapValues { + val (config, currentEnvironment) = it + + savedEnvironments[config.id.name] ?: currentEnvironment + } + } + } + .launchIn(CoroutineScope(dispatchers.main)) + } + + override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig { + val apiConfigs = _apiConfigs.value + + val config = apiConfigs.map { it }.firstOrNull { it.key.id == id }?.key + ?: error("Api config with id [$id] not found") + + val currentEnvironment = apiConfigs[config] + ?: error("Current environment of api config with id [$id] not found") + + return config.environmentConfigs.firstOrNull { it.environment == currentEnvironment } + ?: error("Api config with id [$id] doesn't contain environment [$currentEnvironment]") + } + + override suspend fun changeEnvironment(id: String, environment: ApiEnvironment) { + appPreferencesStore.editData { mutablePreferences -> + val updatedMap = mutablePreferences.getObjectMap(PreferencesKeys.apiConfigsEnvironmentKey) + .toMutableMap() + .apply { put(id, environment) } + + mutablePreferences.setObjectMap(PreferencesKeys.apiConfigsEnvironmentKey, updatedMap) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MutableApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MutableApiConfigsManager.kt new file mode 100644 index 0000000000..35107ad6d8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MutableApiConfigsManager.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import kotlinx.coroutines.flow.Flow + +/** + * Mutable [ApiConfigsManager] for change information about the current api environment + * +[REDACTED_AUTHOR] + */ +interface MutableApiConfigsManager : ApiConfigsManager { + + /** Api configs with current [ApiEnvironment] */ + val configs: Flow> + + /** Change api environment [environment] by [id] */ + suspend fun changeEnvironment(id: String, environment: ApiEnvironment) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManager.kt new file mode 100644 index 0000000000..fd5638c151 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManager.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiConfigs +import com.tangem.datasource.api.common.config.ApiEnvironmentConfig + +/** + * Implementation of [ApiConfigsManager] in PROD environment + * + * @property apiConfigs api configs + */ +internal class ProdApiConfigsManager( + private val apiConfigs: ApiConfigs, +) : ApiConfigsManager { + + override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig { + val config = apiConfigs.firstOrNull { it.id == id } + ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI") + + return config.environmentConfigs.firstOrNull { it.environment == config.defaultEnvironment } + ?: error( + "Api config with id [$id] doesn't contain environment [${config.defaultEnvironment}]. " + + "Check ApiConfig's environments is included default environment", + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt index 014f9adf75..c1cc2d5702 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt @@ -16,18 +16,20 @@ interface TangemTechMarketsApi { @Query("offset") offset: Int, @Query("limit") limit: Int, @Query("order") order: String, - @Query("general_coins") generalCoins: Boolean, @Query("search") search: String?, + @Query("timestamp") timestamp: Long?, ): ApiResponse @GET("coins/{coin_id}") suspend fun getCoinMarketData( @Path("coin_id") coinId: String, @Query("currency") currency: String, - ): ApiResponse + @Query("language") language: String, + ): ApiResponse @GET("coins/{coin_id}/history") suspend fun getCoinChart( + @Path("coin_id") coinId: String, @Query("currency") currency: String, @Query("interval") interval: String, ): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketDetailsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt similarity index 50% rename from core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketDetailsResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt index 227496a66e..ef485e1829 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketDetailsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt @@ -3,130 +3,129 @@ package com.tangem.datasource.api.markets.models.response import com.squareup.moshi.Json import java.math.BigDecimal -data class TokenMarketDetailsResponse( +data class TokenMarketInfoResponse( @Json(name = "id") val id: String, @Json(name = "name") val name: String, @Json(name = "symbol") val symbol: String, - @Json(name = "active") - val active: Boolean, @Json(name = "current_price") val currentPrice: BigDecimal, @Json(name = "price_change_percentage") - val priceChangePercentage: PriceChangePercentage, + val priceChangePercentage: PriceChangePercentage?, @Json(name = "networks") - val networks: List, + val networks: List?, @Json(name = "short_description") val shortDescription: String?, @Json(name = "full_description") val fullDescription: String?, @Json(name = "insights") - val insights: List?, + val insights: Insights?, @Json(name = "metrics") - val metrics: Metrics, + val metrics: Metrics?, @Json(name = "links") - val links: Links, + val links: Links?, @Json(name = "price_performance") - val pricePerformance: PricePerformance, + val pricePerformance: PricePerformance?, ) { + data class PriceChangePercentage( @Json(name = "24h") - val h24: BigDecimal, + val day: BigDecimal?, @Json(name = "1w") - val week1: BigDecimal, + val week: BigDecimal?, @Json(name = "1m") - val month1: BigDecimal, + val month: BigDecimal?, @Json(name = "3m") - val month3: BigDecimal, + val threeMonths: BigDecimal?, @Json(name = "6m") - val month6: BigDecimal, + val sixMonths: BigDecimal?, @Json(name = "1y") - val year1: BigDecimal, + val year: BigDecimal?, @Json(name = "all_time") - val allTime: BigDecimal, + val allTime: BigDecimal?, ) data class Network( @Json(name = "network_id") val networkId: String, @Json(name = "exchangeable") - val exchangeable: Boolean, + val exchangeable: Boolean = false, @Json(name = "contract_address") - val contractAddress: String, - @Json(name = "decimalCount") - val decimalCount: Int, + val contractAddress: String?, + @Json(name = "decimal_count") + val decimalCount: Int?, ) - data class Insight( + data class Insights( @Json(name = "holders_change") - val holdersChange: Change, + val holdersChange: Change?, @Json(name = "liquidity_change") - val liquidityChange: Change, + val liquidityChange: Change?, @Json(name = "buy_pressure_change") - val buyPressureChange: Change, + val buyPressureChange: Change?, @Json(name = "experienced_buyer_change") - val experiencedBuyerChange: Change, - ) { - data class Change( - @Json(name = "1d") - val day1: Int, - @Json(name = "1w") - val week1: Int, - @Json(name = "1m") - val month1: Int, - ) - } + val experiencedBuyerChange: Change?, + ) + + data class Change( + @Json(name = "24h") + val day: BigDecimal?, + @Json(name = "1w") + val week: BigDecimal?, + @Json(name = "1m") + val month: BigDecimal?, + ) data class Metrics( @Json(name = "market_rating") - val marketRating: Int, + val marketRating: Int?, @Json(name = "circulating_supply") - val circulatingSupply: BigDecimal, + val circulatingSupply: BigDecimal?, @Json(name = "market_cap") - val marketCap: BigDecimal, + val marketCap: BigDecimal?, @Json(name = "volume_24h") - val volume24h: BigDecimal, + val volume24h: BigDecimal?, @Json(name = "total_supply") - val totalSupply: BigDecimal, + val totalSupply: BigDecimal?, @Json(name = "fully_diluted_valuation") - val fullyDilutedValuation: BigDecimal, + val fullyDilutedValuation: BigDecimal?, ) data class Links( @Json(name = "official_links") - val officialLinks: List = emptyList(), + val officialLinks: List?, @Json(name = "social") - val social: List = emptyList(), + val social: List?, @Json(name = "repository") - val repository: List = emptyList(), + val repository: List?, @Json(name = "blockchain_site") - val blockchainSite: List = emptyList(), + val blockchainSite: List?, ) data class Link( @Json(name = "title") - val title: String?, + val title: String, @Json(name = "id") - val id: String, + val id: String?, @Json(name = "link") - val url: String, + val link: String, ) data class PricePerformance( - @Json(name = "high_price") - val highPrice: Price, + @Json(name = "24h") + val day: Range?, + @Json(name = "1m") + val month: Range?, + @Json(name = "all_time") + val allTime: Range?, + ) + + data class Range( @Json(name = "low_price") - val lowPrice: Price, - ) { - data class Price( - @Json(name = "24h") - val h24: BigDecimal, - @Json(name = "1m") - val month1: BigDecimal, - @Json(name = "all_time") - val allTime: BigDecimal, - ) - } + val low: BigDecimal?, + @Json(name = "high_price") + val high: BigDecimal?, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt index b1e8675206..a0f287971f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt @@ -14,6 +14,8 @@ data class TokenMarketListResponse( val limit: Int, @Json(name = "offset") val offset: Int, + @Json(name = "timestamp") + val timestamp: Long? = null, ) { data class Token( @Json(name = "id") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt index 7a4f33b288..6138444c45 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.api.stakekit.models.response.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.math.BigDecimal @JsonClass(generateAdapter = true) data class AddressArgumentDTO( @@ -10,7 +11,7 @@ data class AddressArgumentDTO( @Json(name = "network") val network: String? = null, @Json(name = "minimum") - val minimum: Double? = null, + val minimum: BigDecimal? = null, @Json(name = "maximum") - val maximum: Double? = null, + val maximum: BigDecimal? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt index 5be46fc967..1cd438732e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt @@ -19,7 +19,7 @@ data class BalanceDTO( @Json(name = "groupId") val groupId: String, @Json(name = "type") - val type: BalanceType, + val type: BalanceTypeDTO, @Json(name = "amount") val amount: BigDecimal, @Json(name = "date") @@ -38,7 +38,7 @@ data class BalanceDTO( val providerId: String?, ) { - enum class BalanceType { + enum class BalanceTypeDTO { @Json(name = "available") AVAILABLE, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt index bf8956f11f..cff84d1ce7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt @@ -105,13 +105,13 @@ data class YieldDTO( @Json(name = "type") val type: String, @Json(name = "rewardSchedule") - val rewardSchedule: String, + val rewardSchedule: RewardScheduleDTO, @Json(name = "cooldownPeriod") val cooldownPeriod: PeriodDTO, @Json(name = "warmupPeriod") val warmupPeriod: PeriodDTO, @Json(name = "rewardClaiming") - val rewardClaiming: String, + val rewardClaiming: RewardClaimingDTO, @Json(name = "defaultValidator") val defaultValidator: String?, @Json(name = "minimumStake") @@ -135,6 +135,41 @@ data class YieldDTO( @Json(name = "enabled") val enabled: Boolean, ) + + enum class RewardScheduleDTO { + @Json(name = "block") + BLOCK, + + @Json(name = "week") + WEEK, + + @Json(name = "hour") + HOUR, + + @Json(name = "day") + DAY, + + @Json(name = "month") + MONTH, + + @Json(name = "era") + ERA, + + @Json(name = "epoch") + EPOCH, + + UNKNOWN, + } + + enum class RewardClaimingDTO { + @Json(name = "auto") + AUTO, + + @Json(name = "manual") + MANUAL, + + UNKNOWN, + } } enum class RewardTypeDTO { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt index 30396de002..9ad42062ca 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt @@ -16,7 +16,7 @@ class StakeKitErrorResponse( @Json(name = "code") val code: String? = null, @Json(name = "countryCode") - val countryCode: String, + val countryCode: String?, @Json(name = "regionCode") val regionCode: String? = null, @Json(name = "tags") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 9fe41bf3a5..23edacd61b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -3,7 +3,10 @@ package com.tangem.datasource.api.tangemTech import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.promotion.models.PromotionInfoResponse import com.tangem.datasource.api.tangemTech.models.* +import com.tangem.datasource.api.utils.ReadTimeout +import com.tangem.datasource.config.models.ProviderModel import retrofit2.http.* +import java.util.concurrent.TimeUnit /** * Interface of Tangem Tech API @@ -15,6 +18,7 @@ interface TangemTechApi { @GET("coins") suspend fun getCoins( + @Header("Cache-Control") cacheControl: String = "max-age=600", @Query("contractAddress") contractAddress: String? = null, @Query("exchangeable") exchangeable: Boolean? = null, @Query("networkIds") networkIds: String? = null, @@ -29,7 +33,9 @@ interface TangemTechApi { suspend fun getRates(@Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String): RatesResponse @GET("currencies") - suspend fun getCurrencyList(): ApiResponse + suspend fun getCurrencyList( + @Header("Cache-Control") cacheControl: String = "max-age=600", + ): ApiResponse @GET("geo") suspend fun getUserCountryCode(): GeoResponse @@ -59,15 +65,6 @@ interface TangemTechApi { @Body startReferralBody: StartReferralBody, ): ReferralResponse - @GET("shops") - suspend fun getShopInfo(@Query(value = "name") name: String): ShopResponse - - @GET("sales") - suspend fun getSalesInfo( - @Query(value = "locale") locale: String, - @Query(value = "shops") shops: String, - ): SalesResponse - @GET("quotes") suspend fun getQuotes( @Query("currencyId") currencyId: String, @@ -76,7 +73,10 @@ interface TangemTechApi { ): ApiResponse @GET("promotion") - suspend fun getPromotionInfo(@Query("programName") name: String): ApiResponse + suspend fun getPromotionInfo( + @Query("programName") name: String, + @Header("Cache-Control") cacheControl: String = "max-age=600", + ): ApiResponse @GET("settings/{wallet_id}") suspend fun getUserTokensSettings( @@ -131,4 +131,8 @@ interface TangemTechApi { @GET("features") suspend fun getFeatures(): ApiResponse + + @ReadTimeout(duration = 5, unit = TimeUnit.SECONDS) + @GET("networks/providers") + suspend fun getBlockchainProviders(): Map> } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechServiceApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechServiceApi.kt deleted file mode 100644 index 7bd52cc1e2..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechServiceApi.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.datasource.api.tangemTech - -import com.tangem.datasource.config.models.ProviderModel -import retrofit2.http.GET - -/** - * Tangem Tech API for app services - * -[REDACTED_AUTHOR] - */ -interface TangemTechServiceApi { - - @GET("networks/providers") - suspend fun getBlockchainProviders(): Map> -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/utils/TimeountAnnotations.kt b/core/datasource/src/main/java/com/tangem/datasource/api/utils/TimeountAnnotations.kt new file mode 100644 index 0000000000..3e521944cd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/utils/TimeountAnnotations.kt @@ -0,0 +1,39 @@ +package com.tangem.datasource.api.utils + +import java.util.concurrent.TimeUnit + +/** + * Set connect timeout of request + * + * @property duration duration + * @property unit unit + * + * @see "HttpClientExt.applyTimeoutAnnotations" + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +internal annotation class ConnectTimeout(val duration: Int, val unit: TimeUnit) + +/** + * Set read timeout of request + * + * @property duration duration + * @property unit unit + * + * @see "HttpClientExt.applyTimeoutAnnotations" + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +internal annotation class ReadTimeout(val duration: Int, val unit: TimeUnit) + +/** + * Set write timeout of request + * + * @property duration duration + * @property unit unit + * + * @see "HttpClientExt.applyTimeoutAnnotations" + */ +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +internal annotation class WriteTimeout(val duration: Int, val unit: TimeUnit) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt b/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt index 36bba25d83..af8b2a28c7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt @@ -1,7 +1,6 @@ package com.tangem.datasource.config import com.tangem.blockchain.common.* -import com.tangem.datasource.BuildConfig import com.tangem.datasource.config.ConfigManager.Companion.IS_CREATING_TWIN_CARDS_ALLOWED import com.tangem.datasource.config.ConfigManager.Companion.IS_TOP_UP_ENABLED import com.tangem.datasource.config.models.Config @@ -109,7 +108,8 @@ internal class ConfigManagerImpl @Inject constructor() : ConfigManager { sprinklr = configValues.sprinklr, walletConnectProjectId = configValues.walletConnectProjectId, tangemComAuthorization = configValues.tangemComAuthorization, - express = if (BuildConfig.ENVIRONMENT == "dev") configValues.devExpress else configValues.express, + express = configValues.express, + devExpress = configValues.devExpress, stakeKitApiKey = configValues.stakeKitApiKey, ) } @@ -154,6 +154,8 @@ internal class ConfigManagerImpl @Inject constructor() : ConfigManager { zkSyncEra = GetBlockAccessToken(rest = accessTokens.zksync?.jsonRPC), polygonZkEvm = GetBlockAccessToken(rest = accessTokens.polygonZkevm?.jsonRPC), base = GetBlockAccessToken(rest = accessTokens.base?.jsonRPC), + blast = GetBlockAccessToken(jsonRpc = accessTokens.blast?.jsonRPC), + filecoin = GetBlockAccessToken(jsonRpc = accessTokens.filecoin?.jsonRPC), ) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt b/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt index 3f54641502..2b8cadac71 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt @@ -17,5 +17,6 @@ data class Config( val walletConnectProjectId: String = "", val tangemComAuthorization: String? = null, val express: ExpressModel? = null, + val devExpress: ExpressModel? = null, val stakeKitApiKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt b/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt index a6929291e1..7a65c4ed6c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt @@ -76,6 +76,8 @@ data class GetBlockAccessTokens( @Json(name = "polygon-zkevm") val polygonZkevm: GetBlockToken?, @Json(name = "zksync") val zksync: GetBlockToken?, @Json(name = "base") val base: GetBlockToken?, + @Json(name = "blast") val blast: GetBlockToken?, + @Json(name = "filecoin") val filecoin: GetBlockToken?, ) @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt new file mode 100644 index 0000000000..1dd7fb700c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -0,0 +1,40 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.Express +import com.tangem.datasource.api.common.config.StakeKit +import com.tangem.datasource.api.common.config.TangemTech +import com.tangem.datasource.config.ConfigManager +import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.lib.auth.StakeKitAuthProvider +import com.tangem.utils.version.AppVersionProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(SingletonComponent::class) +internal object ApiConfigsModule { + + @Provides + @IntoSet + fun provideExpressConfig( + configManager: ConfigManager, + expressAuthProvider: ExpressAuthProvider, + appVersionProvider: AppVersionProvider, + ): ApiConfig { + return Express(configManager, expressAuthProvider, appVersionProvider) + } + + @Provides + @IntoSet + fun provideStakeKitConfig(stakeKitAuthProvider: StakeKitAuthProvider): ApiConfig { + return StakeKit(stakeKitAuthProvider) + } + + @Provides + @IntoSet + fun provideTangemTechConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemTech(appVersionProvider) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 6951732450..c692a451c2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -3,19 +3,22 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiConfigs +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager +import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApiV2 -import com.tangem.datasource.api.tangemTech.TangemTechServiceApi -import com.tangem.datasource.utils.RequestHeader -import com.tangem.datasource.utils.RequestHeader.* -import com.tangem.datasource.utils.addHeaders -import com.tangem.datasource.utils.addLoggers -import com.tangem.lib.auth.ExpressAuthProvider -import com.tangem.lib.auth.StakeKitAuthProvider +import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.utils.* +import com.tangem.datasource.utils.RequestHeader.AppVersionPlatformHeaders +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides @@ -30,34 +33,45 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -class NetworkModule { +internal object NetworkModule { + + private const val DEV_V1_TANGEM_TECH_BASE_URL = "https://devapi.tangem-tech.com/v1/" + private const val PROD_V2_TANGEM_TECH_BASE_URL = "https://api.tangem-tech.com/v2/" + private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L + + @Provides + @Singleton + fun provideApiConfigManager( + apiConfigs: ApiConfigs, + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): ApiConfigsManager { + return if (BuildConfig.TESTER_MENU_ENABLED) { + DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers) + } else { + ProdApiConfigsManager(apiConfigs) + } + } @Provides @Singleton fun provideExpressApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - expressAuthProvider: ExpressAuthProvider, - appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, + appLogsStore: AppLogsStore, ): TangemExpressApi { - val url = if (BuildConfig.ENVIRONMENT == "dev") { - STAGE_EXPRESS_BASE_URL - } else { - PROD_EXPRESS_BASE_URL - } - return Retrofit.Builder() - .addConverterFactory(MoshiConverterFactory.create(moshi)) - .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) - .baseUrl(url) - .client( - OkHttpClient.Builder() - .addHeaders(Express(expressAuthProvider)) - .addHeaders(AppVersionPlatformHeaders(appVersionProvider)) - .addLoggers(context) - .build(), - ) - .build() - .create(TangemExpressApi::class.java) + return createApi( + id = ApiConfig.ID.Express, + moshi = moshi, + context = context, + apiConfigsManager = apiConfigsManager, + clientBuilder = { + addInterceptor( + NetworkLogsSaveInterceptor(appLogsStore), + ) + }, + ) } @Provides @@ -65,20 +79,20 @@ class NetworkModule { fun provideStakeKitApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - stakeKitAuthProvider: StakeKitAuthProvider, + apiConfigsManager: ApiConfigsManager, + appLogsStore: AppLogsStore, ): StakeKitApi { - return Retrofit.Builder() - .addConverterFactory(MoshiConverterFactory.create(moshi)) - .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) - .baseUrl(STAKEKIT_BASE_URL) - .client( - OkHttpClient.Builder() - .addHeaders(StakeKit(stakeKitAuthProvider)) - .addLoggers(context) - .build(), - ) - .build() - .create(StakeKitApi::class.java) + return createApi( + id = ApiConfig.ID.StakeKit, + moshi = moshi, + context = context, + apiConfigsManager = apiConfigsManager, + clientBuilder = { + addInterceptor( + NetworkLogsSaveInterceptor(appLogsStore), + ) + }, + ) } @Provides @@ -86,11 +100,18 @@ class NetworkModule { fun provideTangemTechApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): TangemTechApi { - return provideTangemTechApiInternal(moshi, context, appVersionProvider, PROD_V1_TANGEM_TECH_BASE_URL) + return createApi( + id = ApiConfig.ID.TangemTech, + moshi = moshi, + context = context, + apiConfigsManager = apiConfigsManager, + clientBuilder = { applyTimeoutAnnotations() }, + ) } + // TODO: It will be deleted in the future or refactored using ApiConfig @Provides @Singleton fun provideTangemTechApiV2( @@ -98,7 +119,12 @@ class NetworkModule { @ApplicationContext context: Context, appVersionProvider: AppVersionProvider, ): TangemTechApiV2 { - return provideTangemTechApiInternal(moshi, context, appVersionProvider, PROD_V2_TANGEM_TECH_BASE_URL) + return provideTangemTechApiInternal( + moshi = moshi, + context = context, + appVersionProvider = appVersionProvider, + baseUrl = PROD_V2_TANGEM_TECH_BASE_URL, + ) } @Provides @@ -109,28 +135,15 @@ class NetworkModule { @ApplicationContext context: Context, appVersionProvider: AppVersionProvider, ): TangemTechApi { - return provideTangemTechApiInternal(moshi, context, appVersionProvider, DEV_V1_TANGEM_TECH_BASE_URL) - } - - @Provides - @Singleton - fun provideTangemTechServiceApi( - @NetworkMoshi moshi: Moshi, - @ApplicationContext context: Context, - appVersionProvider: AppVersionProvider, - ): TangemTechServiceApi { return provideTangemTechApiInternal( moshi = moshi, context = context, appVersionProvider = appVersionProvider, - baseUrl = PROD_V1_TANGEM_TECH_BASE_URL, - timeouts = Timeouts( - callTimeoutSeconds = TANGEM_TECH_SERVICE_TIMEOUT_SECONDS, - ), - requestHeaders = listOf(AppVersionPlatformHeaders(appVersionProvider)), + baseUrl = DEV_V1_TANGEM_TECH_BASE_URL, ) } + // TODO: [REDACTED_JIRA] @Provides @DevTangemApi @Singleton @@ -159,9 +172,10 @@ class NetworkModule { appVersionProvider: AppVersionProvider, baseUrl: String, timeouts: Timeouts = Timeouts(), - requestHeaders: List = listOf(CacheControlHeader, AppVersionPlatformHeaders(appVersionProvider)), + requestHeaders: List = listOf(AppVersionPlatformHeaders(appVersionProvider)), ): T { val client = OkHttpClient.Builder() + .applyTimeoutAnnotations() .let { builder -> var b = builder if (timeouts.callTimeoutSeconds != null) { @@ -195,25 +209,34 @@ class NetworkModule { .create(T::class.java) } + private inline fun createApi( + id: ApiConfig.ID, + moshi: Moshi, + context: Context, + apiConfigsManager: ApiConfigsManager, + clientBuilder: OkHttpClient.Builder.() -> OkHttpClient.Builder = { this }, + ): T { + val environmentConfig = apiConfigsManager.getEnvironmentConfig(id) + + return Retrofit.Builder() + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) + .baseUrl(environmentConfig.baseUrl) + .client( + OkHttpClient.Builder() + .applyApiConfig(id, apiConfigsManager) + .addLoggers(context) + .clientBuilder() + .build(), + ) + .build() + .create(T::class.java) + } + private data class Timeouts( val callTimeoutSeconds: Long? = null, val connectTimeoutSeconds: Long? = null, val readTimeoutSeconds: Long? = null, val writeTimeoutSeconds: Long? = null, ) - - private companion object { - const val STAKEKIT_BASE_URL = "https://api.stakek.it/v1/" - const val PROD_EXPRESS_BASE_URL = "https://express.tangem.com/v1/" - const val STAGE_EXPRESS_BASE_URL = "[REDACTED_ENV_URL]" - const val DEV_EXPRESS_BASE_URL = "[REDACTED_ENV_URL]" - - const val DEV_V1_TANGEM_TECH_BASE_URL = "https://devapi.tangem-tech.com/v1/" - - const val PROD_V1_TANGEM_TECH_BASE_URL = "https://api.tangem-tech.com/v1/" - const val PROD_V2_TANGEM_TECH_BASE_URL = "https://api.tangem-tech.com/v2/" - - const val TANGEM_TECH_SERVICE_TIMEOUT_SECONDS = 5L - const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L - } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt new file mode 100644 index 0000000000..38a05e8d79 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt @@ -0,0 +1,69 @@ +package com.tangem.datasource.local.logs + +import androidx.datastore.preferences.core.MutablePreferences +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.joda.time.DateTime +import javax.inject.Inject + +/** + * Store for saving app logs + * + * @property appPreferencesStore app preferences store + * @param dispatchers coroutine dispatcher provider + * +[REDACTED_AUTHOR] + */ +class AppLogsStore @Inject constructor( + private val appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, +) { + + private val scope = CoroutineScope(dispatchers.io) + private val mutex = Mutex() + + /** Save log [message] */ + fun saveLogMessage(message: String) { + val newLogs = DateTime.now().millis.toString() to message + + appPreferencesStore.editDataWithLock { preferences -> + val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) + + preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs) + } + } + + /** Delete deprecated logs if file size exceeds [maxSize] */ + fun deleteDeprecatedLogs(maxSize: Int) { + appPreferencesStore.editDataWithLock { preferences -> + val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) + + var sum = 0 + preferences.setObjectMap( + key = PreferencesKeys.APP_LOGS_KEY, + value = savedLogs.entries + .sortedBy(Map.Entry::key) + .takeLastWhile { + sum += it.value.length + sum < maxSize + } + .associate { it.key to it.value }, + ) + } + } + + private fun AppPreferencesStore.editDataWithLock( + transform: suspend AppPreferencesStore.(MutablePreferences) -> Unit, + ) { + scope.launch { + mutex.withLock { + editData(transform) + } + } + } +} \ No newline at end of file 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 bb2c5fa1b0..9b01024cc8 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 @@ -95,6 +95,8 @@ object PreferencesKeys { booleanPreferencesKey(name = "isTokenSwapPromoOkxShown") } + val apiConfigsEnvironmentKey by lazy { stringPreferencesKey(name = "apiConfigsEnvironment") } + // region Permission fun getShouldShowPermission(permission: String) = booleanPreferencesKey("shouldShowPushPermission_$permission") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt index 26be12c002..a7fd3cafef 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt @@ -125,7 +125,7 @@ suspend inline fun AppPreferencesStore.storeObjectMap( } /** Get map with [String] key and value [V] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectMap(key: Preferences.Key): Map { +suspend inline fun AppPreferencesStore.getObjectMapSync(key: Preferences.Key): Map { val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) val adapter = moshi.adapter>(type) @@ -135,6 +135,14 @@ suspend inline fun AppPreferencesStore.getObjectMap(key: Preferences .orEmpty() } +/** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */ +inline fun AppPreferencesStore.getObjectMap(key: Preferences.Key): Flow> { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) + + return data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() } +} + /** Get set of data [T] by string [key], or empty if data is not found */ suspend inline fun AppPreferencesStore.getObjectSetSync(key: Preferences.Key): Set { val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt index f9c11e6471..a049816e5c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt @@ -1,9 +1,6 @@ package com.tangem.datasource.local.preferences.utils import android.content.Context -import android.os.Build -import androidx.annotation.DoNotInline -import androidx.annotation.RequiresApi import androidx.datastore.core.DataMigration import androidx.datastore.preferences.core.* import java.io.File @@ -72,27 +69,11 @@ internal class SharedPreferencesKeyMigration( } private fun deleteSharedPreferences(context: Context, name: String) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!Api24Impl.deleteSharedPreferences(context, name)) { - throw IOException("Unable to delete SharedPreferences: $name") - } - } else { - val prefsFile = getSharedPrefsFile(context, name) - val prefsBackup = getSharedPrefsBackup(prefsFile) + val prefsFile = getSharedPrefsFile(context, name) + val prefsBackup = getSharedPrefsBackup(prefsFile) - prefsFile.delete() - prefsBackup.delete() - } - } - - @RequiresApi(Build.VERSION_CODES.N) - private object Api24Impl { - - @JvmStatic - @DoNotInline - fun deleteSharedPreferences(context: Context, name: String): Boolean { - return context.deleteSharedPreferences(name) - } + prefsFile.delete() + prefsBackup.delete() } private fun getSharedPrefsFile(context: Context, name: String): File { diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/HttpClientExt.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/HttpClientExt.kt index 5490d5f8f3..bd6e251c2b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/HttpClientExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/HttpClientExt.kt @@ -3,9 +3,17 @@ package com.tangem.datasource.utils import android.content.Context import com.chuckerteam.chucker.api.ChuckerInterceptor import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.createNetworkLoggingInterceptor +import com.tangem.datasource.api.utils.ConnectTimeout +import com.tangem.datasource.api.utils.ReadTimeout +import com.tangem.datasource.api.utils.WriteTimeout +import com.tangem.utils.Provider import okhttp3.Interceptor import okhttp3.OkHttpClient +import retrofit2.Invocation /** Extension for adding headers [requestHeaders] to every [OkHttpClient] request */ internal fun OkHttpClient.Builder.addHeaders(vararg requestHeaders: RequestHeader): OkHttpClient.Builder { @@ -13,7 +21,7 @@ internal fun OkHttpClient.Builder.addHeaders(vararg requestHeaders: RequestHeade Interceptor { chain -> val request = chain.request().newBuilder().apply { requestHeaders - .flatMap(RequestHeader::values) + .flatMap { it.values.toList() } .forEach { addHeader(it.first, it.second.invoke()) } }.build() @@ -22,10 +30,51 @@ internal fun OkHttpClient.Builder.addHeaders(vararg requestHeaders: RequestHeade ) } +/** + * Apply timeout annotations [Interceptor]. + * Add this [Interceptor] to [OkHttpClient] if use timeout annotations for retrofit requests. + */ +internal fun OkHttpClient.Builder.applyTimeoutAnnotations(): OkHttpClient.Builder { + return addInterceptor( + Interceptor { chain -> + val request = chain.request() + val tag = request.tag(Invocation::class.java) + val connectionTimeout = tag?.method()?.getAnnotation(ConnectTimeout::class.java) + val readTimeout = tag?.method()?.getAnnotation(ReadTimeout::class.java) + val writeTimeout = tag?.method()?.getAnnotation(WriteTimeout::class.java) + + chain + .apply { + connectionTimeout?.let { withConnectTimeout(timeout = it.duration, unit = it.unit) } + } + .apply { + readTimeout?.let { withReadTimeout(timeout = it.duration, unit = it.unit) } + } + .apply { + writeTimeout?.let { withWriteTimeout(timeout = it.duration, unit = it.unit) } + } + .proceed(request) + }, + ) +} + +/** Extension for adding headers [requestHeaders] to every [OkHttpClient] request */ +internal fun OkHttpClient.Builder.addHeaders(requestHeaders: Map>): OkHttpClient.Builder { + return addInterceptor( + Interceptor { chain -> + val request = chain.request().newBuilder().apply { + requestHeaders.forEach { addHeader(it.key, it.value.invoke()) } + }.build() + + chain.proceed(request) + }, + ) +} + /** * Extension for logging each [OkHttpClient] request * - * @param level logging level. By default, only the request body. + * @param context context */ internal fun OkHttpClient.Builder.addLoggers(context: Context? = null): OkHttpClient.Builder { return if (BuildConfig.LOG_ENABLED) { @@ -36,4 +85,28 @@ internal fun OkHttpClient.Builder.addLoggers(context: Context? = null): OkHttpCl } else { this } +} + +/** + * Apply api config + * + * @param id class of [ApiConfig] + * @param apiConfigsManager api configs manager + */ +internal fun OkHttpClient.Builder.applyApiConfig( + id: ApiConfig.ID, + apiConfigsManager: ApiConfigsManager, +): OkHttpClient.Builder { + return if (BuildConfig.TESTER_MENU_ENABLED) { + addInterceptor( + interceptor = SwitchEnvironmentInterceptor( + id = id, + apiConfigsManager = apiConfigsManager, + ), + ) + } else { + val headers = apiConfigsManager.getEnvironmentConfig(id).headers + + this.addHeaders(headers) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt new file mode 100644 index 0000000000..ee74b94ab5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt @@ -0,0 +1,197 @@ +package com.tangem.datasource.utils + +import com.tangem.datasource.local.logs.AppLogsStore +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.internal.http.promisesBody +import okio.Buffer +import okio.EOFException +import okio.GzipSource +import okio.IOException +import org.json.JSONArray +import org.json.JSONObject +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit + +private const val JSON_INDENT_SPACES = 4 + +/** + * Interceptor for save network requests and responses logs + * + * @property appLogsStore app logs store + * +[REDACTED_AUTHOR] + */ +internal class NetworkLogsSaveInterceptor( + private val appLogsStore: AppLogsStore, +) : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + logRequestMessage(chain, request) + + val startNs = System.nanoTime() + val response: Response + try { + response = chain.proceed(request) + } catch (e: Exception) { + appLogsStore.saveLogMessage("<-- HTTP FAILED: $e") + throw e + } + + logResponseMessage(response, startNs) + + return response + } + + private fun logRequestMessage(chain: Interceptor.Chain, request: Request) { + val connection = chain.connection() + val connectionProtocol = if (connection != null) " ${connection.protocol()}" else "" + + appLogsStore.saveLogMessage( + "--> ${request.method} ${request.url}$connectionProtocol\n" + + createRequestEndMessage(request), + ) + } + + private fun createRequestEndMessage(request: Request): String { + val requestBody = request.body + val method = request.method + + return if (requestBody == null) { + "--> END $method" + } else if (bodyHasUnknownEncoding(request.headers)) { + "--> END $method (encoded body omitted)" + } else if (requestBody.isDuplex()) { + "--> END $method (duplex request body omitted)" + } else if (requestBody.isOneShot()) { + "--> END $method (one-shot body omitted)" + } else { + val buffer = Buffer() + requestBody.writeTo(buffer) + + val contentType = requestBody.contentType() + val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8 + + if (buffer.isProbablyUtf8()) { + val json = buffer.readString(charset).beautifyJson() + "$json\n--> END $method (${requestBody.contentLength()}-byte body)" + } else { + "--> END $method (binary ${requestBody.contentLength()}-byte body omitted)" + } + } + } + + private fun logResponseMessage(response: Response, startNs: Long) { + val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs) + + val responseMessage = if (response.message.isEmpty()) "" else ' ' + response.message + val startMessage = "<-- ${response.code}$responseMessage ${response.request.url} " + + "(${tookMs}ms)" + + val responseHeaders = response.headers + val responseBody = response.body!! + val contentLength = responseBody.contentLength() + + val message = if (!response.promisesBody()) { + "<-- END HTTP" + } else if (bodyHasUnknownEncoding(response.headers)) { + "<-- END HTTP (encoded body omitted)" + } else { + val source = responseBody.source() + source.request(Long.MAX_VALUE) + var buffer = source.buffer + + var gzippedLength: Long? = null + if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) { + gzippedLength = buffer.size + GzipSource(buffer.clone()).use { gzippedResponseBody -> + buffer = Buffer() + buffer.writeAll(gzippedResponseBody) + } + } + + val contentType = responseBody.contentType() + val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8 + + if (!buffer.isProbablyUtf8()) { + "<-- END HTTP (binary ${buffer.size}-byte body omitted)" + } else { + val json = if (contentLength != 0L) { + buffer.clone().readString(charset).beautifyJson() + } else { + "" + } + + val end = if (gzippedLength != null) { + "<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)" + } else { + "<-- END HTTP (${buffer.size}-byte body)" + } + + "$json\n$end" + } + } + + appLogsStore.saveLogMessage(startMessage + "\n" + message) + } + + private fun bodyHasUnknownEncoding(headers: Headers): Boolean { + val contentEncoding = headers["Content-Encoding"] ?: return false + return !contentEncoding.equals("identity", ignoreCase = true) && + !contentEncoding.equals("gzip", ignoreCase = true) + } + + private fun Buffer.isProbablyUtf8(): Boolean { + try { + val prefix = Buffer() + val byteCount = size.coerceAtMost(maximumValue = 64) + copyTo(out = prefix, offset = 0, byteCount = byteCount) + + @Suppress("MagicNumber", "UnusedPrivateMember") + for (i in 0 until 16) { + if (prefix.exhausted()) break + + val codePoint = prefix.readUtf8CodePoint() + if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) return false + } + + return true + } catch (_: EOFException) { + return false + } + } + + private fun String.beautifyJson(): String { + beautifyIfObject(json = this)?.let { + return it + } + + beautifyIfArray(json = this)?.let { + return it + } + + return this + } + + private fun beautifyIfObject(json: String): String? { + return try { + JSONObject(json).toString(JSON_INDENT_SPACES) + } catch (e: Exception) { + null + } + } + + private fun beautifyIfArray(json: String): String? { + return try { + JSONArray(json).toString(JSON_INDENT_SPACES) + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt index 215b56e9f8..b6ed8b7645 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt @@ -1,8 +1,7 @@ package com.tangem.datasource.utils import com.tangem.datasource.api.common.AuthProvider -import com.tangem.lib.auth.ExpressAuthProvider -import com.tangem.lib.auth.StakeKitAuthProvider +import com.tangem.utils.Provider import com.tangem.utils.version.AppVersionProvider /** @@ -10,31 +9,20 @@ import com.tangem.utils.version.AppVersionProvider * * @param pairs header name and header value pairs */ -sealed class RequestHeader(vararg pairs: Pair String>) { +sealed class RequestHeader(vararg pairs: Pair>) { /** Header list */ - val values: List String>> = pairs.toList() + val values: Map> = pairs.toMap() - data object CacheControlHeader : RequestHeader("Cache-Control" to { "max-age=600" }) + data object CacheControlHeader : RequestHeader("Cache-Control" to Provider { "max-age=600" }) class AuthenticationHeader(authProvider: AuthProvider) : RequestHeader( - "card_id" to { authProvider.getCardId() }, - "card_public_key" to { authProvider.getCardPublicKey() }, - ) - - class Express(expressAuthProvider: ExpressAuthProvider) : RequestHeader( - "api-key" to { expressAuthProvider.getApiKey() }, - "user-id" to { expressAuthProvider.getUserId() }, - "session-id" to { expressAuthProvider.getSessionId() }, + "card_id" to Provider(authProvider::getCardId), + "card_public_key" to Provider(authProvider::getCardPublicKey), ) class AppVersionPlatformHeaders(appVersionProvider: AppVersionProvider) : RequestHeader( - "version" to { appVersionProvider.versionName }, - "platform" to { "android" }, - ) - - class StakeKit(stakeKitAuthProvider: StakeKitAuthProvider) : RequestHeader( - "X-API-KEY" to { stakeKitAuthProvider.getApiKey() }, - "accept" to { "application/json" }, + "version" to Provider(appVersionProvider::versionName), + "platform" to Provider { "android" }, ) } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockConfigManager.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockConfigManager.kt new file mode 100644 index 0000000000..4e81af810b --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/MockConfigManager.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.config.Loader +import com.tangem.datasource.config.models.Config +import com.tangem.datasource.config.models.ConfigModel +import com.tangem.datasource.config.models.ExpressModel + +/** + * Mock [ConfigManager] implementation for [ProdApiConfigsManagerTest] + * +[REDACTED_AUTHOR] + */ +internal class MockConfigManager : ConfigManager { + + override val config = Config( + express = ExpressModel(apiKey = ProdApiConfigsManagerTest.EXPRESS_API_KEY, signVerifierPublicKey = ""), + devExpress = ExpressModel(apiKey = ProdApiConfigsManagerTest.EXPRESS_DEV_API_KEY, signVerifierPublicKey = ""), + ) + + override suspend fun load(configLoader: Loader, onComplete: ((config: Config) -> Unit)?) = Unit + override fun turnOff(name: String) = Unit + override fun resetToDefault(name: String) = Unit +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt new file mode 100644 index 0000000000..d818bd6c9f --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -0,0 +1,149 @@ +package com.tangem.datasource.api.common.config.managers + +import com.google.common.truth.Truth +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.config.* +import com.tangem.datasource.api.common.config.ApiConfig.Companion.DEBUG_BUILD_TYPE +import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUILD_TYPE +import com.tangem.datasource.api.common.config.ApiConfig.Companion.INTERNAL_BUILD_TYPE +import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE +import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE +import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.lib.auth.StakeKitAuthProvider +import com.tangem.utils.Provider +import com.tangem.utils.version.AppVersionProvider +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +private val configManager = MockConfigManager() +private val appVersionProvider = mockk() +private val expressAuthProvider = mockk() +private val stakeKitAuthProvider = mockk() + +// Don't forget to add new config !!! +private val API_CONFIGS = setOf( + Express(configManager, expressAuthProvider, appVersionProvider), + TangemTech(appVersionProvider), + StakeKit(stakeKitAuthProvider), +) + +/** +[REDACTED_AUTHOR] + */ +@RunWith(Parameterized::class) +internal class ProdApiConfigsManagerTest(private val model: Model) { + + private val manager = ProdApiConfigsManager(API_CONFIGS) + + @Before + fun setup() { + every { appVersionProvider.versionName } returns VERSION_NAME + every { expressAuthProvider.getUserId() } returns EXPRESS_USER_ID + every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID + every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY + } + + @Test + fun test_getEnvironmentConfig() { + val actual = manager.getEnvironmentConfig(id = model.id) + + Truth.assertThat(actual.environment).isEqualTo(model.expected.environment) + Truth.assertThat(actual.baseUrl).isEqualTo(model.expected.baseUrl) + + Truth.assertThat(actual.headers.mapValues { it.value() }) + .isEqualTo(model.expected.headers.mapValues { it.value() }) + } + + data class Model(val id: ApiConfig.ID, val expected: ApiEnvironmentConfig) + + internal companion object { + + const val VERSION_NAME = "debug" + const val EXPRESS_USER_ID = "express_user_id" + const val EXPRESS_SESSION_ID = "express_session_id" + const val EXPRESS_API_KEY = "express_api_key" + const val EXPRESS_DEV_API_KEY = "express_dev_api_key" + const val STAKE_KIT_API_KEY = "stake_kit_api_key" + + @JvmStatic + @Parameterized.Parameters + fun data(): Collection = API_CONFIGS.map { + when (it) { + is Express -> createExpressModel() + is TangemTech -> createTangemTechModel() + is StakeKit -> createStakeKitModel() + } + } + + private fun createExpressModel(): Model { + val environment = when (BuildConfig.BUILD_TYPE) { + DEBUG_BUILD_TYPE -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + MOCKED_BUILD_TYPE, + -> ApiEnvironment.STAGE + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + + return Model( + id = ApiConfig.ID.Express, + expected = ApiEnvironmentConfig( + environment = environment, + baseUrl = when (BuildConfig.BUILD_TYPE) { + DEBUG_BUILD_TYPE -> "[REDACTED_ENV_URL]" + INTERNAL_BUILD_TYPE, + MOCKED_BUILD_TYPE, + -> "[REDACTED_ENV_URL]" + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> "https://express.tangem.com/v1/" + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + }, + headers = mapOf( + "api-key" to Provider { + if (environment == ApiEnvironment.PROD) EXPRESS_API_KEY else EXPRESS_DEV_API_KEY + }, + "user-id" to Provider { EXPRESS_USER_ID }, + "session-id" to Provider { EXPRESS_SESSION_ID }, + "version" to Provider { VERSION_NAME }, + "platform" to Provider { "android" }, + ), + ), + ) + } + + private fun createTangemTechModel(): Model { + return Model( + id = ApiConfig.ID.TangemTech, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.tangem-tech.com/v1/", + headers = mapOf( + "version" to Provider { VERSION_NAME }, + "platform" to Provider { "android" }, + ), + ), + ) + } + + private fun createStakeKitModel(): Model { + return Model( + id = ApiConfig.ID.StakeKit, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.stakek.it/v1/", + headers = mapOf( + "X-API-KEY" to Provider { STAKE_KIT_API_KEY }, + "accept" to Provider { "application/json" }, + ), + ), + ) + } + } +} \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt index bf84d3e382..dcd5c85434 100644 --- a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt @@ -18,7 +18,7 @@ internal class DefaultDeepLinksRegistry : DeepLinksRegistry { val received = intent.data ?: return false var hasMatch = false - Timber.d( + Timber.i( """ Received deep link intent |- Received URI: $received @@ -33,7 +33,7 @@ internal class DefaultDeepLinksRegistry : DeepLinksRegistry { val params = getParams(expected, received) - Timber.d( + Timber.i( """ Matched deep link |- Expected URI: $expected @@ -45,7 +45,7 @@ internal class DefaultDeepLinksRegistry : DeepLinksRegistry { } if (!hasMatch) { - Timber.d( + Timber.i( """ No match found for deep link |- Received URI: $received diff --git a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json index fa1c02c031..f13343fc36 100644 --- a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json @@ -3,10 +3,6 @@ "name": "NEW_CARD_SCANNING_ENABLED", "version": "undefined" }, - { - "name": "REDESIGNED_SEND_SCREEN_ENABLED", - "version": "5.10.0" - }, { "name": "LOCAL_USER_LOGS_ENABLED", "version": "undefined" @@ -38,5 +34,13 @@ { "name": "MARKETS_ENABLED", "version": "undefined" + }, + { + "name": "HOME_SCREEN_CALLBACKS_REFACTORING_ENABLED", + "version": "5.14.0" + }, + { + "name": "NEW_MANAGE_TOKENS", + "version": "undefined" } ] diff --git a/core/featuretoggles/src/test/kotlin/com/tangem/core/featuretoggle/manager/ProdFeatureTogglesManagerTest.kt b/core/featuretoggles/src/test/kotlin/com/tangem/core/featuretoggle/manager/ProdFeatureTogglesManagerTest.kt index a8b90c4af8..35af42ae54 100644 --- a/core/featuretoggles/src/test/kotlin/com/tangem/core/featuretoggle/manager/ProdFeatureTogglesManagerTest.kt +++ b/core/featuretoggles/src/test/kotlin/com/tangem/core/featuretoggle/manager/ProdFeatureTogglesManagerTest.kt @@ -6,21 +6,13 @@ import com.tangem.core.featuretoggle.storage.FeatureToggle import com.tangem.core.featuretoggle.storage.FeatureTogglesStorage import com.tangem.core.featuretoggle.utils.associateToggles import com.tangem.core.featuretoggle.version.VersionProvider -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerifyOrder -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.verifyAll -import kotlinx.coroutines.ExperimentalCoroutinesApi +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.Test /** [REDACTED_AUTHOR] */ -@OptIn(ExperimentalCoroutinesApi::class) internal class ProdFeatureTogglesManagerTest { private val localFeatureTogglesStorage = mockk() diff --git a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt index 158de617e7..db1c27bbbc 100644 --- a/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt +++ b/core/pagination/src/main/java/com/tangem/pagination/BatchListSource.kt @@ -114,6 +114,12 @@ private class DefaultBatchListSource loadMoreActionJob?.cancel() reloadActionJob?.cancel() stopAllUpdates() + + state.value = BatchListState( + data = emptyList(), + status = PaginationStatus.InitialLoading, + ) + reloadActionJob = scope.launchFetch { reloadTask(action) } @@ -221,11 +227,6 @@ private class DefaultBatchListSource } private suspend fun reloadTask(action: BatchAction.Reload) { - state.value = BatchListState( - data = emptyList(), - status = PaginationStatus.InitialLoading, - ) - val res = runCatching { batchFetcher.fetchFirst(action.requestParams) }.getOrElse { diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 6d99e6bd52..1e9ed93914 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1,7 +1,7 @@ Netzwerk wählen - Benutzerdef. Token hinzuf. + Token anlegen Token verwalten Sende nur %1$s ( %2$s ) vom %3$s -Netzwerk an diese Adresse. Die Verwendung anderer Token und Netzwerke kann zum Verlust von Geldern führen. So scannt man @@ -88,7 +88,8 @@ Kopieren Adresse kopieren Erstellen - Benutzerdef. + %1$s (%2$s) + Eigen %d tag %d tage @@ -126,6 +127,7 @@ Passphrase Einfügen %1$s-%2$s + %1$s — %2$s Weiterlesen Empfangen Ablehnen @@ -278,7 +280,7 @@ Rückmeldung Feedback zu Tangem Eine Transaktion kann nicht gesendet werden - Aktuelle Transaktion + Nur diese Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Sie die Verwendung Ihres Tokens für den Swap genehmigen. Gib das Genehmigungslimit für das ausgewählte Token an Betrag %s @@ -341,7 +343,7 @@ Verfügbare Netzwerke Mein Portfolio Markt - Um Adressen für ausgewählte Netzwerke zu generieren, musst du eine Tangem-Karte scannen/ einsetzen + Um Adressen für ausgewählte Netzwerke zu generieren, musst du eine Tangem-Karte scannen Die Daten konnten nicht geladen werden... Schnelle Aktionen Ergebnis @@ -446,7 +448,7 @@ Möchtest du den Aktivierungsprozess abbrechen? Erste Schritte Für die Karte, die du hinzufügen möchtest, wurde bereits eine andere Wallets erstellt. Wenn du Guthaben auf dieser Wallets hast, hebe es bitte ab, setze diese Karte zurück und füge sie als Backup hinzu. - Erstellen eines Backups + Backups anlegen Lese mehr über die Seed-Phrase leer @@ -499,7 +501,7 @@ Gruppe erstellen Nach Guthaben Token organisieren - Gruppierung aufh. + Gruppe löschen Wählen aus der Galerie aus Einstellungen Du hast keinen Zugriff auf deine Kamera gewährt @@ -638,7 +640,6 @@ Die Transaktion wurde erfolgreich signiert und an den Blockchain-Knoten gesendet. Die Walletbilanz wird aktualisiert %1$s ist ein Vermögenswert im Tron-Netzwerk. Um die Gebühr zu berechnen und eine Transaktion durchzuführen, musst du etwas Tron (TRX) auf deinem Konto einzahlen. Ungültige Adresse - %1$s (%2$s) Transaktion gesendet Bereite das Scannen der Karte vor, die du einrichten möchtest. Entferne diese Wallet @@ -647,7 +648,7 @@ Aktiv Um deine Kryptos zu unstaken, klick hier. Die Anzahl der zu stakenden Krypros muss mindesten %s betragen - nicht gestakte beanspruche + Nicht gestakte beanspruche Jährliche prozentuale Rendite Die jährliche prozentuale Rendite, die du durch die Teilnahme am Staking erzielen kannst. Effektiver Jahreszins @@ -671,7 +672,10 @@ Die zugewiesene Zeit für die Aktivierung der Teilnahme am Staking. Migrieren Natives Staking - Mit Staking kannst du %1$s verdienen. Deine Staking-Belohnungen kommen alle ~%2$s Tage. + Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jeden tag. + Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jede Stunde. + Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jeden Monat. + Mit dem Staking kannst Du %1$s verdienen. Deine Staking-Belohnungen erhältst Du jede Woche. Verdiene Staking-Belohnungen Die Belohnungen werden sofort nach dem unstaken gestoppt. Der unstakingprozess dauert %s. Erneut binden @@ -679,10 +683,11 @@ Belohnungen erneut staken Widerrufen Neuwahl - automatisch - händisch + Automatisch + Händisch Block Tag + Täglich Epoche Ära Stunde @@ -693,7 +698,7 @@ Mehr staken Stake %s Staking beenden%s - gelocktes unlocken + Gelocktes unlocken Unstaken Prüfe, was nicht eingesetzt wurde, um dein Vermögen zu beanspruchen Staking beenden @@ -834,7 +839,7 @@ Aktivierungsfehler Laut den Entwicklern des BNB-Netzes wird die Unterstützung für den BEP-2-Standard im Juni 2024 enden. Um den Verlust von Vermögenswerten mit diesem Standard zu vermeiden, konvertiere bitte in den BEP-20 Standard. Nutze gerne unseren Swap-Service, um sie auf das BNB Smart Chain Netzwerk zu übertragen. BNB Beacon Chain wird abgeschaltet - ausbaufähig + Ausbaufähig Gefällt mir OK, habe ich verstanden! Echt toll! diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 3c55b9ec8c..d39ab86d92 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -6,10 +6,10 @@ Envoyez uniquement %1$s (%2$s) depuis les réseaux %3$s à cette adresse. L\'utilisation d\'autres jetons et réseaux peut entraîner une perte de fonds. Comment scanner Demander de l\'aide - Réessayer + Réessayez Cette fonctionnalité est désactivée en mode démo Raison : %s - Impossible d\'envoyer une transaction + Impossible d\'effectuer une transaction Le sélectionné ne prend pas en charge le réseau %1$s Pour activer le cryptage de la blockchain %1$s, vous devrez réinitialiser le portefeuille aux paramètres d\'usine. Veuillez retirer vos fonds avant de le faire pour vous assurer de ne pas les perdre, puis terminez le processus de réinitialisation. L\'accès au portefeuille actuel ne sera pas possible après la réinitialisation. Les jetons du réseau %1$s ne sont pas pris en charge par cette carte en raison d\'une limitation du micrologiciel. @@ -17,7 +17,7 @@ Cette carte n\'est pas conçue pour fonctionner avec Tangem Frais par défaut Activez les frais par défaut pour définir automatiquement les frais de transaction et ignorer la page Frais lors de l\'envoi de fonds. Vous pouvez toujours revenir sur cette page si nécessaire. - Allez dans les paramètres pour activer l\'authentification biométrique dans le Tangem App + Accédez aux paramètres pour activer l\'authentification biométrique dans l\'application Tangem Activer l\'authentification biométrique Cela supprimera tous les codes d\'accès des portefeuilles enregistrés. Toute opération ultérieure avec le portefeuille nécessitera la soumission du code d\'accès. La suppression de la carte enregistrée supprime de l\'application tous les portefeuilles enregistrés et leurs codes d\'accès. @@ -35,7 +35,7 @@ Compris Les soldes sont masqués Veuillez scanner la carte - Veuillez réessayer dans 30 secondes ou scanner la carte + Veuillez réessayer dans 30 secondes ou scannez la carte Trop de tentatives Vous avez désactivé l\'authentification biométrique sur votre téléphone et ne pourrez pas enregistrer de portefeuilles dans l\'application. Pour enregistrer des portefeuilles, veuillez activer la fonction d\'authentification biométrique dans les paramètres de votre téléphone. Démarrer le processus de sauvegarde @@ -86,6 +86,7 @@ Copier Copier l\'adresse Créer + %1$s (%2$s) Personnalisé %d jour @@ -97,7 +98,7 @@ Activer Activé Erreur - Explorer + Explorez Explorez l\'historique des transactions Explorateur Commissions @@ -123,6 +124,7 @@ Passphrase Coller %1$s-%2$s + %1$s — %2$s En savoir plus Recevoir Rejeter @@ -133,13 +135,13 @@ Rechercher Rechercher des jetons Seed phrase - Sélectionner une action + Sélectionnez une action Vendre Envoyer Le serveur n\'est pas disponible, veuillez réessayer plus tard Partager - Signer - Signer et envoyer + Signez + Signez et envoyez Enjeu Staking Démarrer @@ -236,7 +238,7 @@ En attente du dépôt En attente du dépôt Remboursé - Je vous envoie + Transfert à votre compte En cours d\'envoi Envoyé Données fournies par le fournisseur. Le montant estimé est sujet à modification en raison des conditions du marché. @@ -268,8 +270,8 @@ Impossible de scanner une carte Commentaires Commentaires sur Tangem - Impossible d\'envoyer une transaction - Transaction en cours + Impossible d\'effectuer une transaction + Cette transaction Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange. Spécifiez la limite approuvée pour le jeton sélectionné Montant %s @@ -283,7 +285,7 @@ Pour créer le portefeuille, appuyez sur la carte comme indiqué ci-dessus et ne la retirez pas jusqu\'à la fin de l\'opération Appuyez sur la carte n°%s du portefeuille Posez pour scanner - Touchez pour signer + Tapez pour signer Posez la carte Vous avez mis à jour vos données biométriques, scannez votre carte pour entrer Votre solde doit être supérieur à la valeur des frais pour effectuer un transfert @@ -326,11 +328,20 @@ Pour commencer à acheter, échanger ou recevoir cet actif, ajoutez ce jeton à au moins 1 réseau Cet actif n\'est pas disponible Ajouter au portfolio + Ajoutez un jeton + Réseaux disponibles Mon portfolio Marché - Pour générer des adresses pour les réseaux sélectionnés, vous devez scanner votre carte Tangem. + Pour générer des adresses pour les réseaux sélectionnés, vous devez scanner votre carte Tangem + Impossible de charger les données… + Résultat + Voir les jetons de moins de 100 000 $ de capitalisation boursière + Afficher les jetons + Aucun résultat + Sélectionnez un réseau Sélectionnez un portefeuille Trier par + À propos de %s Idées Liens Métriques @@ -362,7 +373,7 @@ Finaliser la sauvegarde Recevoir des crypto-monnaies Scannez la carte principale - Ignorer pour plus tard + Finir plus tard Comment ça marche ? Générons toutes les clés sur votre carte et créons un portefeuille sécurisé Créer un portefeuille @@ -374,7 +385,7 @@ Succès ! Dans ce cas, vous devrez recommencer depuis le début. Voulez-vous quitter le processus d\'activation ? - Initialiser + Initialisation en cours Un autre portefeuille a déjà été créé sur la carte que vous essayez d\'ajouter. Si vous avez des fonds dans ce portefeuille, veuillez les retirer, puis réinitialiser cette carte et l\'ajouter comme sauvegarde. Sauvegarde en cours En savoir plus sur les seed phrases @@ -488,7 +499,7 @@ Vous avez spécifié une commission inférieure au montant recommandé, ce qui pourrait entraîner un retard dans votre transaction. Continuer? Raison : %1$s\nCode : %2$s La transaction est incomplète - Somme + Montant Vous pouvez définir vos frais de transaction en ajustant la valeur dans le champ Satoshi par vByte. Les frais qui seront facturés pour votre transaction. Vous pouvez définir votre propre valeur. Frais maximum @@ -499,7 +510,7 @@ KAS pour UTXO %1$s, %2$s Adresse - Destination Tag + ID de destination Entrez l\'adresse L\'adresse est la même que celle de votre portefeuille Tag invalide. Il ne sera pas ajouté à la transaction. @@ -557,7 +568,7 @@ Appuyez sur n\'importe quel champ pour le modifier Envoyer %s Vous envoyez **%1$s** incluant des frais de réseau de %2$s - Vous envoyez **%1$s** and %2$s + Vous envoyez **%1$s** et %2$s Envoi de %s Total Sera envoyé %1$s et %2$s @@ -566,9 +577,8 @@ La transaction a été signée avec succès et envoyée au nœud de blockchain. Le solde du portefeuille sera mis à jour après un certain temps %1$s est un actif du réseau Tron. Pour calculer les frais et effectuer une transaction, déposez du Tron (TRX) sur votre compte. Adresse incorrecte - %1$s (%2$s) Transaction envoyée - Préparez-vous à numériser la carte que vous souhaitez configurer. + Scannez la carte que vous souhaitez configurer. Oublier le portefeuille Cela supprimera le portefeuille de l\'application. Le portefeuille lui-même peut être ajouté à nouveau. Nom @@ -594,9 +604,9 @@ Période d\'échauffement Le temps imparti pour activer la participation au staking. Native staking - Le staking vous permet d\'en gagner %1$s. Vos récompenses de staking arrivent tous les ~%2$s jours. Gagnez des récompenses de staking Récompenses + Stake verrouillé Staker plus Non-staké Vérifiez non-stakés pour réclamer vos actifs @@ -611,12 +621,13 @@ Le portefeuille pour tous Découvrez Tangem Échangez, achetez des NFT, faites des prêts et des dépôts dans plus de 100 services décentralisés différents - Compatible Web 3.0 + Compatible avec Web 3.0 Le montant comprend :\n• les frais du fournisseur de services\n• les frais de réseau pour l\'envoi de %s depuis l\'échange vers l\'adresse de l\'utilisateur. Le montant comprend les frais du fournisseur de services. Frais Tous les échanges décentralisés nécessitent des approbations pour empêcher les smart contracts d\'accéder à votre portefeuille sans votre permission. Par conception, les smart contracts ne peuvent pas accéder à vos jetons sans votre approbation. En « déverrouillant » vos jetons, vous autorisez le smart contract 1-inch à les dépenser. Les mineurs du réseau reçoivent des frais de gaz (payés par vous) pour enregistrer cette action sur la blockchain. Vous pouvez échanger votre jeton après avoir donné votre approbation. Approuver + Erreur d\'estimation des frais. Veuillez envoyer vos commentaires à l\'équide de support. Vous échangez Échanger ce montant de jetons sélectionnés aura un impact significatif sur les prix et réduira votre résultat. Fonds insuffisants @@ -660,6 +671,7 @@ Opération de : %s à : %s + Réessayez Vous avez scanné la même carte. Pour créer un portefeuille jumeau, vous devez scanner la carte portant le numéro %d Vous avez scanné une mauvaise carte jumelle. S\'il vous plaît, essayez-en un autre Celle que vous tenez dans vos mains et l\'autre portant le numéro %s\n\nLes deux cartes peuvent être utilisées pour extraire des fonds de ce portefeuille. @@ -692,7 +704,7 @@ Détails de la transaction :\nDe : %1$s\nÀ : %2$s\nMontant : %3$s Le presse-papiers contient un code WalletConnect. Utilisez la valeur copiée ou scannez le QR-code Demande de création d\'une transaction pour %1$s\n%2$s\n\nMontant : %3$s\nFrais : %4$s\nTotal : %5$s\nSolde : %6$s - Impossible d\'envoyer la transaction. Fonds insuffisants. + Impossible d\'effectuer la transaction. Fonds insuffisants. Échec de l\'établissement de la session WalletConnect. Veuillez réessayer plus tard. Échec de la signature du message. Veuillez réessayer Échec de l\'établissement de la session WalletConnect : erreur de délai d\'attente. Veuillez réessayer plus tard. @@ -731,7 +743,7 @@ BNB Beacon Chain va s\'arrêter de fonctionner Pas terrible J\'aime - Ok, je l\'ai! + Ok, compris! Vraiment cool ! Rafraîchir Vous êtes actuellement en mode démo @@ -750,6 +762,7 @@ Pour effectuer une transaction, vous devez déposer %1$s %2$s Impossible de couvrir %s frais Le montant à recevoir doit être d\'au moins %s + Cela peut se produire car le fournisseur n\'est actuellement pas en mesure d\'échanger la paire que vous avez sélectionnée. Veuillez patienter un instant et réessayer. (Code %@) Service temporairement indisponible Le nombre de jetons à échanger ne doit pas dépasser %s Le montant à échanger doit être d\'au moins %s diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index b8860bd7d8..e7caa7829e 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -14,7 +14,6 @@ Mantieni le modifiche Invia Con successo - Avviso Codice di accesso Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto Mantenimento della carta diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index d5d7502641..e5c43474a1 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -87,6 +87,7 @@ コピー アドレスをコピー 作成 + %1$s ( %2$s ) 設定 %d 日 @@ -124,6 +125,7 @@ パスフレーズ ペースト %1$s-%2$s + %1$s — %2$s 続きを読む 受け取る 拒否 @@ -276,7 +278,7 @@ フィードバック Tangemへのフィードバック 取引を送信できません - 現在の取引 + 取引 ネットワークは、あなたがトークンのスワップを承認していることを確認するために、トークン承認手数料を請求します。 選択したトークンの承認制限を指定します 金額%s @@ -286,7 +288,7 @@ 許可を与える 無制限 カードを注文 - カードをスキャン + スキャン アクセスコードを変更するには、上図のようにカードをタップし、操作が終了するまで取り外さないでください。 パスコードを変更するには、上記のようにカードをタップし、操作が終了するまで取り外さないでください。 ウォレットを作成するには、上記のようにカードをタップし、操作が終了するまで取り外さないでください。 @@ -626,7 +628,6 @@ 取引は正常に署名され、ブロックチェーンノードに送信されました。ウォレットの残高はしばらくして更新されます。 %1$sはTronネットワークのアセットです。手数料を計算して取引を行うには、アカウントにTron(TRX)を入金する必要があります。 無効なアドレス - %1$s ( %2$s ) 取引が送信されました セットアップしたいカードをスキャンするために準備してください。 ウォレット削除 @@ -636,7 +637,7 @@ 資産のステーキングを解除するには、ここをクリックしてください。 ステーキング金額は %s 以上である必要があります ステーキング解除分を請求する - APY + 年率 ステーキングに参加することで得られる年間収益率。 APR 利用可能 @@ -659,7 +660,10 @@ ステーキングへの参加を有効にするために割り当てられた時間。 移行 ネイティブステーキング - ステーキングにより%1$sを獲得できます。ステーキング報酬は ~ %2$s日ごとに届きます。 + ステーキングにより%1$sを獲得できます。ステーキング報酬は毎日受け取れます。 + ステーキングにより%1$sを獲得できます。ステーキング報酬は1時間ごとに受け取れます。 + ステーキングにより%1$sを獲得できます。ステーキング報酬は毎月受け取れます。 + ステーキングにより%1$sを獲得できます。ステーキング報酬は毎週受け取れます。 ステーキング報酬を獲得 ステーキング解除後、報酬の獲得はすぐに停止します。ステーキング解除プロセスには%sかかります。 再結束 @@ -671,6 +675,7 @@ 手動 ブロック + 毎日 エポック 時代 @@ -686,6 +691,7 @@ 資産を請求するために、unstakedを確認してください ステーキング解除 バリデーター + バリデーター 投票する 投票はロックされています 引き出す @@ -842,6 +848,8 @@ 取引を行うには、 %1$s %2$sを入金する必要があります。 %s 手数料を支払えません 受け取る金額は、 %s 以上である必要があります。 + これは、選択したペアがプロバイダーでは現在交換できないために発生する可能性があります。しばらく待ってからもう一度お試しください。(コード%@ ) + 選択されたペアは一時的に利用できません サービスは一時的に利用できません スワップするトークンの量は %s を超えないでください スワップ金額は %s 以上である必要があります diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 33413d2fb5..e89bb61d95 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -47,7 +47,7 @@ Отключите эту опцию, если не хотите, чтобы эта карта использовалась для сброса кодов доступа на другие карты этого кошелька. Обратите внимание, сброс кода также не будет доступен на этой карте. Использовать эту карту для сброса кода доступа на других картах в этом кошельке - Восстановление кода доступа + Восстановление доступа Сбросить Вы уверены, что хотите это сделать? Смена кода доступа @@ -69,6 +69,7 @@ Недостаточно ADA Принять Доступ запрещен + Все Разрешить Применить Одобрение @@ -88,6 +89,7 @@ Копировать Скопировать адрес Создать + %1$s (%2$s) Свое %d день @@ -101,6 +103,7 @@ Включить Включено Ошибка + Обменять Обозреватель Посмотреть историю транзакций Обозреватель @@ -128,6 +131,7 @@ Парольная фраза Вставить %1$s-%2$s + %1$s — %2$s Подробнее Получить Отклонить @@ -339,20 +343,24 @@ Чтобы купить, обменять или получить данный токен вам нужно добавить его к себе в портфель Этот актив недоступен Добавить в портфель + Добавить Доступные сети Мой портфель Рынок Чтобы создать адреса для выбранных сетей, отсканируйте вашу карту Tangem кошелька + Быстрые действия Результат Токены с капитализацией меньше 100к Показать токены Нет результата + Выберите сеть Выберите кошелек 1мин 1год 24ч + Все Опытные покупатели Рейтинг @@ -360,8 +368,22 @@ Лидеры роста Лидеры падения В тренде + О %s + + На основе %d оценки + На основе %d оценок + На основе %d оценок + На основе %d оценок + Сайт блокчейна + Покупательское предпочтение Разница между объемом покупателей и продавцов + Циркулирующее предложение + Общее количество монет, доступных для торговли и находящихся в обращении на рынке + Опытные покупкатели + Полностью разбавленная капитализация + Общая теоретическая стоимость криптовалюты, если все монеты, которые могут существовать, находятся в обращении, включая те, которые в настоящее время не обращаются + Дата создания Высокий Держатели Изменение количества держателей токенов в течение выбранного периода времени @@ -369,15 +391,20 @@ Ссылки Ликвидность Изменение объема ликвидности, доступной для токена в течение указанного периода времени. + Индекс ликвидности Низкий Рыночная капитализация Общая рыночная стоимость криптовалюты, рассчитываемая путем умножения текущей цены монеты на общее количество монет в обращении. Рыночный рейтинг + Максимальный объем Метрики Официальные ссылки + Динамика цены Репозиторий Оценка безопасности Социальные + Общее предложение + Максимальное количество монет или токенов, которое может когда-либо существовать для выбранной криптовалюты. Объем торгов (24ч) Общая сумма криптовалюты, которая была продана за последние 24 часа, показывающая уровень активности и ликвидности на рынке. Вам необходимо установить единый код доступа для защиты всех ваших карт @@ -430,7 +457,7 @@ Ваша seed-фраза - + %d слово %d слова %d слов %d слов @@ -628,11 +655,12 @@ Для завершения стейкинга нажмите сюда Сумма для стейкинга должна быть не менее %s Забрать средства - APY + Годовая процентная ставка Годовой процентный доход, который вы можете получить от участия в стейкинге. APR Доступно Средння ставка вознаграждения + Что такое Стейкинг? %s оценка доходности Позиция в рынке Метрики @@ -650,14 +678,28 @@ Время, необходимое для начала процесса стейкинга и активации процесса начисления наград Переместить Нативный стейкинг - Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый %2$s + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый день. + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый час. + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый месяц. + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждую неделю. Получите награду за стейкинг Награда перестанет начисляться сразу после завершения стейкинга. Процесс завершения длится %s. Повторный стейкинг Застейкать вознаграждения Отозвать Переголосовать + Автоматически + Вручную + Блок + День + Каждый день + Эпоха + Эра + Час + Месяц + Неделя Вознаграждения + Стейкинг закрыт Застейкать еще Застейкать %s Разблокировать @@ -665,7 +707,9 @@ Проверьте процесс завершения стейкинга, чтобы вывести свои средства. Завершение стейкинга Валидатор + Валидаторы Проголосовать + Голосование заблокировано Вывод Держите свои криптосбережения в безопасности. Приватные ключи надежно хранятся на карте. Революционный аппаратный кошелек @@ -685,6 +729,7 @@ Комиссии Подтверждения считаются отраслевым стандартом для всех децентрализованных бирж и защищают ваш кошелек от доступа со стороны смарт-контракта без вашего разрешения. По замыслу смарт-контракты не могут получить доступ к вашим токенам, если вы не одобрите доступ со своей стороны. «Разблокируя» свои токены, вы даете смарт-контракту 1inch разрешение тратить ваши активы. Майнеры сети получают компенсацию за газ (оплачиваемый вами) за запись этого действия в блокчейне. Как только разрешение будет предоставлено, вы сможете обменять свой токен. Подтвердить + Ошибка расчета комиссии. Пожалуйста, отправьте информацию в поддержку. Вы отправляете Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму. Недостаточно средств @@ -728,6 +773,7 @@ Операция от: %s на: %s + Попробовать снова Вы отсканировали ту же карту. Для создания twin-кошелька вам необходимо отсканировать карту с номером %d Вы отсканировали не ту twin-карту. Пожалуйста, попробуйте отсканировать другую Это карта, которую вы держите в руках. У парной карты номер %s.\n\nОбе карты можно использовать для вывода средств из этого кошелька. @@ -794,6 +840,8 @@ Настройки кошелька Tangem Используйте %s или отсканируйте карту, чтобы разблокировать доступ к вашему кошельку + Процесс выдачи разрешения уже в работе и скоро будет завершен + Выдача разрешения Похоже, что процесс активации карт не был завершен корректно. Это могло быть вызвано проблемой взаимодействия с модулем NFC либо некорректным прикладыванием карты к телефону. Пожалуйста, обратитесь в нашу службу поддержки для уточнения деталей. Ошибка активации По решению разработчиков сети BNB стандарт BEP-2 перестанет поддерживаться в июне 2024 года. Чтобы не потерять активы, их необходимо преобразовать в стандарт BEP-20. Используйте функцию обмена в приложении или сторонние сервисы, чтобы перевести средства в cеть BNB Smart Chain. @@ -819,6 +867,8 @@ Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s Невозможно покрыть комиссию %s Сумма получения не может быть менее %s + Это может произойти из-за того, что провайдер временно не предоставляет обмен выбранной вами пары. Пожалуйста, подождите некоторое время и попробуйте снова. (Код %@) + Выбранная пара временно недоступна Cервис временно недоступен Сумма для обмена должна быть не более %s Сумма для обмена должна быть не менее %s 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 bdb802eb7e..d352a7be3c 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -90,6 +90,7 @@ Копіювати Скопіювати адресу Створити + %1$s (%2$s) Власна %d день @@ -130,6 +131,7 @@ Парольна фраза Вставити %1$s-%2$s + %1$s — %2$s Детальніше Отримати Відхилити @@ -282,7 +284,7 @@ Звернення в підтримку Звернення в підтримку Tangem Не вдається відправити транзакцію - Поточна транзакція + Транзакція Мережа стягує комісію за схвалення токену за підтвердження, що саме ви дозволяєте використовувати ваш токен для обміну. Вкажіть ліміт доступу для обраного токена Кількість %s @@ -347,7 +349,7 @@ Доступні мережі Моє портфоліо Маркет - Щоб згенерувати адреси для обраних мереж, потрібно відсканувати свою картку Tangem. + Щоб згенерувати адреси для обраних мереж, потрібно відсканувати свою картку Tangem Не вдалося завантажити дані... Швидкі дії Результат @@ -650,7 +652,6 @@ Транзакція успішно підписана і відправлена до блокчейну. Баланс гаманця буде оновлено через деякий час %1$s — це монета у мережі Tron. Щоб розрахувати комісію та здійснити транзакцію, вам необхідно внести певну кількість Tron (TRX) на свій рахунок. Недійсна адреса - %1$s (%2$s) Трансакцію надіслано Підготуйтеся до сканування картку, яку потрібно налаштувати. Забути гаманець @@ -682,7 +683,10 @@ Відведений час для активації участі в стейкінгу. Перемістити Нативний стейкінг - Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг надходять кожні ~%2$s днів. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щодня. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються кожну годину. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щомісяця. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щотиждня. Отримуйте винагороду за стейкінг Винагороди припиняють нараховуватися одразу після того, як ви знімаєте ставку. Процес зняття займає %s. Зʼєднати ще раз diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index b198f3eee7..eb5bc2de81 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -9,9 +9,6 @@ 原因:%s 無法發送交易 此卡不支持%1$s網路上的代幣因為韌體限制 - 感謝您的反饋。我們會盡快回复 - 你的建議已送出 - 請嘗試完全按照動畫中顯示的方式點擊卡片或請求支持 有困難在掃描卡上嗎? 此卡不適用於此app 轉到設置以在 Tangem App 中啟用生物識別身份驗證 @@ -48,7 +45,7 @@ 生物 購買 您尚未授予相機訪問權限,請更改您的隱私設置 - 刪除 + 删除 關閉 繼續 複製 @@ -56,7 +53,6 @@ 創造 刪除 禁用 - 斷開連接 完成 允許 啟用 @@ -69,7 +65,6 @@ 主卡片 拒絕 重新命名 - 重試 保存設置 搜索 搜尋代幣 @@ -88,7 +83,6 @@ 交易 我了解 無法觸達 - 警告 已複製代幣地址 支持的網路 @@ -121,7 +115,6 @@ App Currency 發行人 簽署 - 如果您忘記密碼,您將無法使用您的資金。無法恢復代碼 更多 檢查您的網路連接或切換到其他網絡 服務條款 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 16dcf0b043..79135b1d0d 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -88,6 +88,7 @@ Copy Copy address Create + %1$s (%2$s) Custom %d day @@ -99,6 +100,7 @@ Enable Enabled Error + Exchange Explore Explore transaction history Explorer @@ -126,6 +128,7 @@ Passphrase Paste %1$s-%2$s + %1$s — %2$s Read more Receive Reject @@ -337,7 +340,7 @@ To buy, exchange, or receive this asset, add it to your portfolio This asset is not available Add to portfolio - Add token + Add Available networks My portfolio Market @@ -634,7 +637,6 @@ Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while %1$s is an asset in the Tron network. To calculate the fee and make a transaction you need to deposit some Tron (TRX) in your account. Invalid address - %1$s (%2$s) Transaction sent Prepare to scan card you want to setup. Forget wallet @@ -667,7 +669,10 @@ The allocated time for activating participation in staking. Migrate Native staking - Staking allow you to earn %1$s. Your staking rewards arrive every ~%2$s days. + Staking allow you to earn %1$s. Your staking rewards arrive every day. + Staking allow you to earn %1$s. Your staking rewards arrive every hour. + Staking allow you to earn %1$s. Your staking rewards arrive every month. + Staking allow you to earn %1$s. Your staking rewards arrive every week. Earn staking rewards Rewards stop accruing immediately after you unstake. The unstaking process takes %s. Rebond @@ -679,6 +684,7 @@ Manual Block Day + Each day Epoch Era Hour @@ -827,6 +833,8 @@ Wallet settings Tangem Use %s or scan a card to unlock access to your wallet + The permission-granting process is currently underway and will be completed shortly + Approval in Progress It seems that the card activation was not completed correctly. This could be due to an issue with your device\'s NFC module or incorrect tapping of the card to your device. Please contact our Support team for assistance. Activation error According to BNB network developers, support for the BEP-2 standard will end in June 2024. To avoid losing assets with this standard, please convert them to the BEP-20 standard. Use our swap service or third-party services to transfer funds to the BNB Smart Chain network. @@ -852,6 +860,8 @@ To make a transaction you need to deposit some %1$s %2$s Unable to cover %s fee The amount to receive must be at least %s + This may occur because the provider is currently unable to exchange your selected pair. Please wait a moment and try again. (Code %@) + Selected pair temporarily unavailable Service temporarily unavailable The amount of tokens to be swapped must not exceed %s The amount to swap must be at least %s diff --git a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt index 49a90c5af6..0db95df8d1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/UiDependencies.kt @@ -2,14 +2,14 @@ package com.tangem.core.ui import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Stable -import com.tangem.core.ui.haptic.HapticManager +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler import com.tangem.core.ui.theme.AppThemeModeHolder @Stable interface UiDependencies { - val hapticManager: HapticManager + val vibratorHapticManager: VibratorHapticManager val appThemeModeHolder: AppThemeModeHolder diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt index 7ef56f015e..9667a00860 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Buttons.kt @@ -10,6 +10,7 @@ import androidx.compose.material.ButtonColors import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R import com.tangem.core.ui.components.buttons.common.* @@ -26,6 +27,7 @@ fun TextButton( onClick: () -> Unit, modifier: Modifier = Modifier, colors: ButtonColors = TangemButtonsDefaults.defaultTextButtonColors, + textStyle: TextStyle = TangemTheme.typography.button, enabled: Boolean = true, ) { TangemButton( @@ -37,6 +39,7 @@ fun TextButton( showProgress = false, colors = colors, size = TangemButtonSize.Text, + textStyle = textStyle, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt index a920fda100..72a9f3a11e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.components import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.ime import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalDensity @@ -14,11 +15,15 @@ sealed interface Keyboard { data class Opened(override val height: Dp) : Keyboard - object Closed : Keyboard { + data object Closed : Keyboard { override val height: Dp = 0.dp } } +val Keyboard.isOpened: Boolean + @Stable + get() = this is Keyboard.Opened + /** * Allows to subscribe to a soft keyboard to detect when it's open/closed */ diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt index 2e5b4ee1c2..5d09690136 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Shimmers.kt @@ -9,14 +9,23 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.buttons.PrimarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.* import com.valentinilk.shimmer.* @@ -45,6 +54,70 @@ fun CircleShimmer(modifier: Modifier = Modifier) { ) } +/** + * Shimmer for text + * Height will be set automatically + * + * @param textSizeHeight if true, height will be set to font size height. + */ +@Composable +fun TextShimmer( + style: TextStyle, + modifier: Modifier = Modifier, + text: String = "A", + radius: Dp = TangemTheme.dimens.radius3, + textSizeHeight: Boolean = false, +) { + if (textSizeHeight) { + val lineHeight = with(LocalDensity.current) { style.lineHeight.toDp() } + + Box( + modifier = Modifier.requiredHeight(lineHeight), + contentAlignment = Alignment.CenterStart, + ) { + Text( + modifier = modifier + .clip(RoundedCornerShape(size = radius)) + .shimmer(LocalTangemShimmer.current), + text = text, + style = style.copy(lineHeight = style.fontSize), + maxLines = 1, + ) + } + } else { + Text( + modifier = modifier + .clip(RoundedCornerShape(size = radius)) + .shimmer(LocalTangemShimmer.current), + text = text, + style = style, + maxLines = 1, + ) + } +} + +/** + * Shimmer for SmallButton + * Height and min width will be set automatically + */ +@Composable +fun SmallButtonShimmer(modifier: Modifier = Modifier, withIcon: Boolean = false) { + PrimarySmallButton( + config = SmallButtonConfig( + text = stringReference("B"), + onClick = {}, + icon = if (withIcon) { + TangemButtonIconPosition.Start(iconResId = R.drawable.ic_plus_24) + } else { + TangemButtonIconPosition.None + }, + ), + modifier = modifier + .clip(RoundedCornerShape(size = TangemTheme.dimens.radius16)) + .shimmer(LocalTangemShimmer.current), + ) +} + internal val TangemShimmer: Shimmer @Composable get() = rememberShimmer( @@ -106,6 +179,17 @@ private fun ShimmersPreview() { .height(TangemTheme.dimens.size24), ) CircleShimmer(modifier = Modifier.size(size = TangemTheme.dimens.size42)) + TextShimmer( + style = TangemTheme.typography.body1, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + TextShimmer( + style = TangemTheme.typography.body1, + textSizeHeight = true, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + SmallButtonShimmer(withIcon = true) + SmallButtonShimmer(withIcon = false) } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt index 027109f071..900aafe401 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt @@ -97,7 +97,6 @@ private fun Preview_Grid() { }, content = { GridItems( - itemPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing4), items = persistentListOf( stringReference("Fist item"), stringReference("Second item"), @@ -105,7 +104,6 @@ private fun Preview_Grid() { itemContent = { PreviewItem(text = it) }, - horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) }, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt index 1a40b55863..423100d2e9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlockContent.kt @@ -44,7 +44,6 @@ inline fun InformationBlockContentScope.GridItems( items: ImmutableList, itemContent: @Composable BoxScope.(T) -> Unit, modifier: Modifier = Modifier, - itemPadding: PaddingValues = PaddingValues(all = TangemTheme.dimens.spacing0), verticalAlignment: Alignment.Vertical = Alignment.Top, horizontalArragement: Arrangement.Horizontal = Arrangement.Start, ) { @@ -59,7 +58,6 @@ inline fun InformationBlockContentScope.GridItems( Column( modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { rowItems.fastForEach { row -> @@ -70,10 +68,7 @@ inline fun InformationBlockContentScope.GridItems( ) { row.fastForEach { item -> Box( - modifier = Modifier - .padding(itemPadding) - .weight(1f), - contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f), ) { itemContent(item) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index 67466771ba..6ef3e53d1f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible @@ -29,6 +30,7 @@ inline fun TangemBottomSheet( titleAction: TopAppBarButtonUM? = null, containerColor: Color = TangemTheme.colors.background.primary, addBottomInsets: Boolean = true, + skipPartiallyExpanded: Boolean = true, crossinline content: @Composable ColumnScope.(T) -> Unit, ) { TangemBottomSheet( @@ -36,6 +38,7 @@ inline fun TangemBottomSheet( containerColor = containerColor, addBottomInsets = addBottomInsets, title = { TangemBottomSheetTitle(title = titleText, endButton = titleAction) }, + skipPartiallyExpanded = skipPartiallyExpanded, content = content, ) } @@ -48,6 +51,7 @@ inline fun TangemBottomSheet( config: TangemBottomSheetConfig, containerColor: Color = TangemTheme.colors.background.primary, addBottomInsets: Boolean = true, + skipPartiallyExpanded: Boolean = true, crossinline title: @Composable BoxScope.(T) -> Unit = {}, crossinline content: @Composable ColumnScope.(T) -> Unit, ) { @@ -60,6 +64,7 @@ inline fun TangemBottomSheet( addBottomInsets = addBottomInsets, title = title, content = content, + skipPartiallyExpanded = skipPartiallyExpanded, ) } else { DefaultBottomSheet( @@ -68,6 +73,7 @@ inline fun TangemBottomSheet( addBottomInsets = addBottomInsets, title = title, content = content, + skipPartiallyExpanded = skipPartiallyExpanded, ) } } @@ -78,11 +84,12 @@ inline fun DefaultBottomSheet( config: TangemBottomSheetConfig, containerColor: Color, addBottomInsets: Boolean, + skipPartiallyExpanded: Boolean = true, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { var isVisible by remember { mutableStateOf(value = config.isShow) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) if (isVisible && config.content is T) { BasicBottomSheet( @@ -110,13 +117,15 @@ inline fun PreviewBottomSheet( config: TangemBottomSheetConfig, containerColor: Color, addBottomInsets: Boolean, + skipPartiallyExpanded: Boolean = true, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { BasicBottomSheet( + modifier = Modifier.width(360.dp), config = config, sheetState = SheetState( - skipPartiallyExpanded = true, + skipPartiallyExpanded = skipPartiallyExpanded, initialValue = Expanded, density = LocalDensity.current, ), @@ -137,6 +146,7 @@ inline fun BasicBottomSheet( addBottomInsets: Boolean, crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), + modifier: Modifier = Modifier, ) { val model = config.content as? T ?: return @@ -145,7 +155,7 @@ inline fun BasicBottomSheet( ModalBottomSheet( // FIXME temporary solution to fix height of the bottom sheet - modifier = Modifier.sizeIn(maxHeight = LocalWindowSize.current.height - statusBarHeight), + modifier = modifier.heightIn(max = LocalWindowSize.current.height - statusBarHeight), onDismissRequest = config.onDismissRequest, sheetState = sheetState, containerColor = containerColor, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonIconPosition.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonIconPosition.kt index 2c3821e659..0e6169baa3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonIconPosition.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonIconPosition.kt @@ -11,7 +11,7 @@ sealed interface TangemButtonIconPosition { data class End(@DrawableRes override val iconResId: Int) : TangemButtonIconPosition - object None : TangemButtonIconPosition { + data object None : TangemButtonIconPosition { @DrawableRes override val iconResId: Int? = null } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt index 59128a93a9..c37307f136 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SimpleTextField.kt @@ -1,12 +1,11 @@ package com.tangem.core.ui.components.fields import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.LocalTextSelectionColors -import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -40,6 +39,7 @@ fun SimpleTextField( textStyle: TextStyle = TangemTheme.typography.body2.copy(color = color), placeholderColor: Color = TangemTheme.colors.text.disabled, readOnly: Boolean = false, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, isValuePasted: Boolean = false, onValuePastedTriggerDismiss: () -> Unit = {}, decorationBox: (@Composable (innerTextField: @Composable () -> Unit) -> Unit)? = null, @@ -54,10 +54,6 @@ fun SimpleTextField( ) } val focusRequester = remember { FocusRequester.Default } - val customTextSelectionColors = TextSelectionColors( - handleColor = TangemTheme.colors.text.accent, - backgroundColor = TangemTheme.colors.text.accent.copy(alpha = 0.3f), - ) val textFieldValue = textFieldValueState.copy(text = value) var lastTextValue by remember(proxyValue, isValuePasted) { textFieldValueState = textFieldValueState.copy( @@ -85,37 +81,36 @@ fun SimpleTextField( } } - CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) { - BasicTextField( - value = textFieldValue, - onValueChange = { newTextFieldValueState -> - textFieldValueState = newTextFieldValueState + BasicTextField( + value = textFieldValue, + onValueChange = { newTextFieldValueState -> + textFieldValueState = newTextFieldValueState - val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text - lastTextValue = newTextFieldValueState.text + val stringChangedSinceLastInvocation = lastTextValue != newTextFieldValueState.text + lastTextValue = newTextFieldValueState.text - if (stringChangedSinceLastInvocation) onValueChange(newTextFieldValueState.text) - }, - textStyle = textStyle.copy(color = color), - cursorBrush = SolidColor(TangemTheme.colors.text.primary1), - singleLine = singleLine, - readOnly = readOnly, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - decorationBox = decorationBox ?: { textValue -> - SimpleTextPlaceholder( - placeholder = placeholder, - value = value, - textStyle = textStyle, - textValue = textValue, - color = placeholderColor, - ) - }, - modifier = modifier - .focusRequester(focusRequester), - ) - } + if (stringChangedSinceLastInvocation) onValueChange(newTextFieldValueState.text) + }, + textStyle = textStyle.copy(color = color), + cursorBrush = SolidColor(TangemTheme.colors.text.primary1), + singleLine = singleLine, + readOnly = readOnly, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + decorationBox = decorationBox ?: { textValue -> + SimpleTextPlaceholder( + placeholder = placeholder, + value = value, + textStyle = textStyle, + textValue = textValue, + color = placeholderColor, + ) + }, + modifier = modifier + .focusRequester(focusRequester), + ) } @Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt index 4d1490891f..6a1b07ab53 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnter.kt @@ -72,7 +72,7 @@ fun InputRowEnter( Column(modifier = Modifier.weight(1f)) { Text( text = title.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = titleColor, ) SimpleTextField( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt index 28c9082f90..a4759a11e2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterAmount.kt @@ -72,7 +72,7 @@ fun InputRowEnterAmount( Column(modifier = Modifier.weight(1f)) { Text( text = title.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = titleColor, ) AmountTextField( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfo.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfo.kt index c9e01fd85f..cde9e9b0dd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfo.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfo.kt @@ -62,7 +62,7 @@ fun InputRowEnterInfo( ) { Text( text = title.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = titleColor, ) Row { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt index f3d8a0c1cc..bf0e7bb5ed 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowEnterInfoAmount.kt @@ -70,7 +70,7 @@ fun InputRowEnterInfoAmount( ) { Text( text = title.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = titleColor, ) Row { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt index 58a61bb00e..d816d77bf6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImage.kt @@ -70,7 +70,7 @@ fun InputRowImage( ) { Text( text = title.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = titleColor, ) Row( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt index 417a7edbec..0442dc06ce 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageBase.kt @@ -1,52 +1,121 @@ package com.tangem.core.ui.components.inputrow +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon 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.draw.clip import androidx.compose.ui.graphics.Color +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 com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.inputrow.inner.InputRowAsyncImage import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +/** + * Input row component with selector + * [Input Row Image](https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=2841-1589&t=u6pOF6lsdpvWLELb-4) + * + * @param subtitle subtitle text + * @param caption caption text + * @param modifier modifier + * @param imageUrl icon to load + * @param subtitleColor subtitle text color + * @param captionColor caption text color + * @param isGrayscaleImage whether to display grayscale image + * @param iconEndRes icon to end of row + * @param extraContent extra content + */ @Composable internal fun InputRowImageBase( subtitle: TextReference, - caption: TextReference, imageUrl: String, modifier: Modifier = Modifier, + caption: TextReference? = null, subtitleColor: Color = TangemTheme.colors.text.primary1, captionColor: Color = TangemTheme.colors.text.tertiary, isGrayscaleImage: Boolean = false, - extraContent: @Composable RowScope.() -> Unit = {}, + iconEndRes: Int? = null, + extraContent: (@Composable RowScope.() -> Unit)? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), modifier = modifier, ) { InputRowAsyncImage( imageUrl = imageUrl, isGrayscale = isGrayscaleImage, modifier = Modifier - .size(TangemTheme.dimens.spacing36), + .size(TangemTheme.dimens.spacing36) + .clip(TangemTheme.shapes.roundedCornersXLarge), ) + SpacerW12() Column { Text( text = subtitle.resolveReference(), style = TangemTheme.typography.subtitle2, color = subtitleColor, ) - Text( - text = caption.resolveAnnotatedReference(), - style = TangemTheme.typography.caption2, - color = captionColor, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), - ) + if (caption != null) { + Text( + text = caption.resolveAnnotatedReference(), + style = TangemTheme.typography.caption2, + color = captionColor, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) + } } - extraContent() + SpacerW12() + if (extraContent != null) { + extraContent() + } else { + SpacerWMax() + } + InputRowEndIcon(iconEndRes) } -} \ No newline at end of file +} + +@Composable +private fun RowScope.InputRowEndIcon(iconRes: Int?) { + AnimatedVisibility( + visible = iconRes != null, + label = "Icon visibility animation", + ) { + val icon = remember(this) { requireNotNull(iconRes) } + Icon( + painter = rememberVectorPainter(image = ImageVector.vectorResource(id = icon)), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing6), + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun InputRowImageBase_Preview() { + TangemThemePreview { + InputRowImageBase( + subtitle = TextReference.Str("Binance"), + caption = TextReference.Str("APR 3,54%"), + imageUrl = "", + iconEndRes = R.drawable.ic_chevron_right_24, + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageChevron.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageChevron.kt deleted file mode 100644 index c341f3ceac..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageChevron.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.tangem.core.ui.components.inputrow - -import android.content.res.Configuration -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview - -/** - * Input row component with selector - * [Input Row Image](https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=2100-842&t=hoBXmDX8NeLrp4p6-4) - * - * @param subtitle subtitle text - * @param caption caption text - * @param imageUrl icon to load - * @param modifier modifier - * @param subtitleColor subtitle text color - * @param captionColor caption text color - */ -@Composable -fun InputRowImageChevron( - subtitle: TextReference, - caption: TextReference, - imageUrl: String, - modifier: Modifier = Modifier, - subtitleColor: Color = TangemTheme.colors.text.primary1, - captionColor: Color = TangemTheme.colors.text.tertiary, - showChevron: Boolean = true, -) { - InputRowImageBase( - subtitle = subtitle, - caption = caption, - imageUrl = imageUrl, - modifier = modifier, - subtitleColor = subtitleColor, - captionColor = captionColor, - ) { - SpacerWMax() - if (showChevron) { - Icon( - painter = painterResource(id = R.drawable.ic_chevron_right_24), - tint = TangemTheme.colors.icon.informative, - contentDescription = null, - ) - } - } -} - -// region Preview -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun InputRowImageChevron_Preview() { - TangemThemePreview { - InputRowImageChevron( - subtitle = stringReference("Binance"), - caption = combinedReference( - resourceReference(R.string.staking_details_apr), - annotatedReference( - buildAnnotatedString { - append(" ") - withStyle(SpanStyle(TangemTheme.colors.text.accent)) { - stringReference("3,54%") - } - }, - ), - ), - imageUrl = "", - ) - } -} -// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt index f9811bbe1a..967547e085 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageInfo.kt @@ -9,10 +9,10 @@ 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.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.core.ui.R import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.extensions.* @@ -33,20 +33,22 @@ import com.tangem.core.ui.res.TangemThemePreview * @param subtitleColor subtitle text color * @param captionColor caption text color * @param isGrayscaleImage whether to display grayscale image + * @param iconEndRes icon to end of row */ @Suppress("LongParameterList") @Composable fun InputRowImageInfo( subtitle: TextReference, - caption: TextReference, infoTitle: TextReference, - infoSubtitle: TextReference, imageUrl: String, modifier: Modifier = Modifier, title: TextReference? = null, + caption: TextReference? = null, + infoSubtitle: TextReference? = null, subtitleColor: Color = TangemTheme.colors.text.primary1, captionColor: Color = TangemTheme.colors.text.tertiary, isGrayscaleImage: Boolean = false, + iconEndRes: Int? = null, ) { Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6), @@ -67,22 +69,41 @@ fun InputRowImageInfo( subtitleColor = subtitleColor, captionColor = captionColor, isGrayscaleImage = isGrayscaleImage, + iconEndRes = iconEndRes, ) { Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), horizontalAlignment = Alignment.End, modifier = Modifier.weight(1f), ) { - EllipsisText( - text = infoTitle.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary1, - ) - EllipsisText( - text = infoSubtitle.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) + if (infoTitle is TextReference.Annotated) { + Text( + text = infoTitle.resolveAnnotatedReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } else { + EllipsisText( + text = infoTitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + infoSubtitle?.let { + if (infoSubtitle is TextReference.Annotated) { + Text( + text = infoSubtitle.resolveAnnotatedReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } else { + EllipsisText( + text = infoSubtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } } } } @@ -92,26 +113,55 @@ fun InputRowImageInfo( @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun InputRowImageInfo_Preview() { +private fun InputRowImageInfo_Preview( + @PreviewParameter(InputRowImageInfoPreviewDataProvider::class) data: InputRowImageInfoPreviewData, +) { TangemThemePreview { InputRowImageInfo( - title = stringReference("Active"), - subtitle = stringReference("Binance"), - caption = combinedReference( - resourceReference(R.string.staking_details_apr), - annotatedReference( - buildAnnotatedString { - append(" ") - withStyle(SpanStyle(TangemTheme.colors.text.accent)) { - stringReference("3,54%") - } - }, - ), - ), - infoTitle = stringReference("5431231231231231231231232 USD"), - infoSubtitle = stringReference("5 SOL"), + title = data.title, + subtitle = data.subtitle, + caption = data.caption, + infoTitle = data.infoTitle, + infoSubtitle = data.infoSubtitle, imageUrl = "", + iconEndRes = R.drawable.ic_chevron_right_24, ) } } + +private class InputRowImageInfoPreviewDataProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + InputRowImageInfoPreviewData( + title = stringReference("Validator"), + subtitle = stringReference("Binance"), + caption = combinedReference( + resourceReference(R.string.staking_details_apr), + annotatedReference( + buildAnnotatedString { + append(" ") + append("3,54%") + }, + ), + ), + infoTitle = stringReference("5431231231231231231231232 USD"), + infoSubtitle = stringReference("5 SOL"), + ), + InputRowImageInfoPreviewData( + title = null, + subtitle = stringReference("Binance"), + caption = null, + infoTitle = stringReference("5431231231231231231231232 USD"), + infoSubtitle = null, + ), + ) +} + +private data class InputRowImageInfoPreviewData( + val title: TextReference?, + val subtitle: TextReference, + val caption: TextReference?, + val infoTitle: TextReference, + val infoSubtitle: TextReference?, +) // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt index 5231d7f896..e1aa081b50 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowImageSelector.kt @@ -40,6 +40,7 @@ import java.math.BigDecimal * @param subtitleColor subtitle text color * @param captionColor caption text color * @param isSelected true if selected + * @param selectorContent selector content */ @Composable fun InputRowImageSelector( @@ -51,6 +52,7 @@ fun InputRowImageSelector( subtitleColor: Color = TangemTheme.colors.text.primary1, captionColor: Color = TangemTheme.colors.text.tertiary, isSelected: Boolean = false, + selectorContent: @Composable ((isSelected: Boolean, isEnabled: Boolean, onSelect: () -> Unit) -> Unit), ) { InputRowImageBase( subtitle = subtitle, @@ -67,7 +69,7 @@ fun InputRowImageSelector( .padding(TangemTheme.dimens.spacing12), ) { SpacerWMax() - TangemRadioButton(isSelected = isSelected, isEnabled = false, onClick = onSelect) + selectorContent(isSelected, false, onSelect) } } @@ -98,6 +100,9 @@ private fun InputRowImageSelectorPreview( imageUrl = "", isSelected = false, onSelect = {}, + selectorContent = { isSelected, isEnabled, onSelect -> + TangemRadioButton(isSelected = isSelected, isEnabled = isEnabled, onClick = onSelect) + }, ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt index b1df3b3e40..8d5363d3cc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipient.kt @@ -75,7 +75,7 @@ fun InputRowRecipient( AnimatedContent(targetState = titleText, label = "Title Change") { Text( text = it.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = color, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipientDefault.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipientDefault.kt index 94f75c6a56..f9941fcb9a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipientDefault.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowRecipientDefault.kt @@ -50,7 +50,7 @@ fun InputRowRecipientDefault( ) { Text( text = title.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = titleColor, ) Row( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt b/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt index 8161f2ff69..336f35d454 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/list/RoundedListWithDividers.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -15,24 +16,57 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +private const val ROUNDED_LIST_WITH_DIVIDERS_HEADER_KEY = "ROUNDED_LIST_WITH_DIVIDERS_HEADER_KEY" +private const val ROUNDED_LIST_WITH_DIVIDERS_FOOTER_KEY = "ROUNDED_LIST_WITH_DIVIDERS_FOOTER_KEY" + @Composable -fun RoundedListWithDividers(rows: List, modifier: Modifier = Modifier) { +fun RoundedListWithDividers( + rows: ImmutableList, + modifier: Modifier = Modifier, + headerContent: (@Composable () -> Unit)? = null, + footerContent: (@Composable () -> Unit)? = null, +) { LazyColumn(modifier = modifier) { - itemsIndexed( - items = rows, - key = { _, item -> item.id }, - ) { index, row -> - InitialInfoContentRow( - startText = row.startText.resolveReference(), - endText = row.endText.resolveReference(), - cornersToRound = getCornersToRound(index, rows.size), - iconClick = row.iconClick, - ) - if (index < rows.lastIndex) { - RoundedListDivider() - } + this.roundedListWithDividersItems( + rows = rows, + headerContent = headerContent, + footerContent = footerContent, + ) + } +} + +fun LazyListScope.roundedListWithDividersItems( + rows: ImmutableList, + headerContent: (@Composable () -> Unit)? = null, + footerContent: (@Composable () -> Unit)? = null, +) { + if (headerContent != null) { + item(key = ROUNDED_LIST_WITH_DIVIDERS_HEADER_KEY) { + headerContent() + } + } + + itemsIndexed( + items = rows, + key = { _, item -> item.id }, + ) { index, row -> + InitialInfoContentRow( + startText = row.startText.resolveReference(), + endText = row.endText.resolveReference(), + cornersToRound = getCornersToRound(index, rows.size), + iconClick = row.iconClick, + ) + if (index < rows.lastIndex) { + RoundedListDivider() + } + } + + if (footerContent != null) { + item(key = ROUNDED_LIST_WITH_DIVIDERS_FOOTER_KEY) { + footerContent() } } } 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 d2c55ed576..a202d8bf21 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 @@ -1,10 +1,7 @@ package com.tangem.core.ui.components.marketprice 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.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,6 +23,11 @@ fun PriceChangeInPercent( modifier: Modifier = Modifier, textStyle: TextStyle = TangemTheme.typography.body2, ) { + if (valueInPercent.isBlank()) { + Box(modifier) + return + } + Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt index 110080210d..801d47263b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -28,7 +29,7 @@ fun TooltipText( text: TextReference, onInfoClick: () -> Unit, modifier: Modifier = Modifier, - useSmallerText: Boolean = false, + textStyle: TextStyle = TangemTheme.typography.caption2, ) { val interactionSource = remember { MutableInteractionSource() } @@ -45,18 +46,16 @@ fun TooltipText( Text( modifier = Modifier.weight(1f, fill = false), text = text.resolveReference(), - style = if (useSmallerText) { - TangemTheme.typography.caption2 - } else { - TangemTheme.typography.subtitle2 - }, + style = textStyle, color = TangemTheme.colors.text.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) IconButton( - modifier = Modifier.requiredSize(TangemTheme.dimens.size24), + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing4) + .requiredSize(TangemTheme.dimens.size16), interactionSource = interactionSource, onClick = onInfoClick, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt b/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt index 78a23e7b11..dabc6f97e2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt @@ -2,15 +2,19 @@ package com.tangem.core.ui.decorations 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.composed import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp import com.tangem.core.ui.res.TangemTheme +@Composable fun Modifier.roundedShapeItemDecoration( currentIndex: Int, lastIndex: Int, addDefaultPadding: Boolean = true, + radius: Dp = TangemTheme.dimens.radius16, ): Modifier = composed { val modifier = if (addDefaultPadding) this.padding(horizontal = TangemTheme.dimens.spacing16) else this val isSingleItem = currentIndex == 0 && lastIndex == 0 @@ -24,7 +28,7 @@ fun Modifier.roundedShapeItemDecoration( Modifier }, ) - .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .clip(shape = RoundedCornerShape(radius)) } currentIndex == 0 -> { modifier @@ -37,8 +41,8 @@ fun Modifier.roundedShapeItemDecoration( ) .clip( shape = RoundedCornerShape( - topStart = TangemTheme.dimens.radius16, - topEnd = TangemTheme.dimens.radius16, + topStart = radius, + topEnd = radius, ), ) } @@ -46,8 +50,8 @@ fun Modifier.roundedShapeItemDecoration( modifier .clip( shape = RoundedCornerShape( - bottomStart = TangemTheme.dimens.radius16, - bottomEnd = TangemTheme.dimens.radius16, + bottomStart = radius, + bottomEnd = radius, ), ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index 33a8bd032e..f59561c295 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -3,7 +3,7 @@ package com.tangem.core.ui.extensions import androidx.annotation.DrawableRes import com.tangem.core.ui.R -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getActiveIconRes(blockchainId: String): Int { return when (blockchainId) { @@ -70,11 +70,14 @@ fun getActiveIconRes(blockchainId: String): Int { "joystream" -> R.drawable.img_joystream_22 "koinos", "koinos/test" -> R.drawable.img_koinos_22 "bittensor" -> R.drawable.img_bittensor_22 + "blast", "blast/test" -> R.drawable.img_blast_22 + "filecoin" -> R.drawable.img_filecoin_22 + "cyber", "cyber/test" -> R.drawable.img_cyber_22 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getActiveIconResByNetworkId(networkId: String): Int { return when (networkId) { @@ -141,6 +144,9 @@ fun getActiveIconResByNetworkId(networkId: String): Int { "joystream" -> R.drawable.img_joystream_22 "koinos", "koinos/test" -> R.drawable.img_koinos_22 "bittensor" -> R.drawable.img_bittensor_22 + "blast", "blast/test" -> R.drawable.img_blast_22 + "filecoin" -> R.drawable.img_filecoin_22 + "cyber", "cyber/test" -> R.drawable.img_cyber_22 else -> R.drawable.ic_alert_24 } } @@ -209,11 +215,14 @@ fun getActiveIconResByCoinId(coinId: String): Int { "joystream" -> R.drawable.img_joystream_22 "koinos", "koinos/test" -> R.drawable.img_koinos_22 "bittensor" -> R.drawable.img_bittensor_22 + "blast", "blast/test" -> R.drawable.img_blast_22 + "filecoin" -> R.drawable.img_filecoin_22 + "cyber", "cyber/test" -> R.drawable.img_cyber_22 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getGreyedOutIconRes(blockchainId: String): Int { return when (blockchainId) { @@ -280,11 +289,14 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "joystream" -> R.drawable.ic_joystream_22 "koinos", "koinos/test" -> R.drawable.ic_koinos_22 "bittensor" -> R.drawable.ic_bittensor_22 + "blast", "blast/test" -> R.drawable.ic_blast_22 + "filecoin" -> R.drawable.ic_filecoin_22 + "cyber", "cyber/test" -> R.drawable.ic_cyber_22 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getGreyedOutIconResByNetworkId(networkId: String): Int { return when (networkId) { @@ -351,6 +363,9 @@ fun getGreyedOutIconResByNetworkId(networkId: String): Int { "joystream" -> R.drawable.ic_joystream_22 "koinos", "koinos/test" -> R.drawable.ic_koinos_22 "bittensor" -> R.drawable.ic_bittensor_22 + "blast", "blast/test" -> R.drawable.ic_blast_22 + "filecoin" -> R.drawable.ic_filecoin_22 + "cyber", "cyber/test" -> R.drawable.ic_cyber_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/DefaultHapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/DefaultHapticManager.kt new file mode 100644 index 0000000000..5a34d92702 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/DefaultHapticManager.kt @@ -0,0 +1,32 @@ +package com.tangem.core.ui.haptic + +import android.view.View +import androidx.core.view.ViewCompat + +internal class DefaultHapticManager( + private val view: View, + private val vibratorHapticManager: VibratorHapticManager?, +) : HapticManager { + + override fun perform(effect: TangemHapticEffect) { + when (effect) { + is TangemHapticEffect.View -> { + effect.androidHapticFeedbackCode?.let { + ViewCompat.performHapticFeedback(view, it) + } + } + is TangemHapticEffect.OneTime -> { + if (vibratorHapticManager != null) { + vibratorHapticManager.performOneTime(effect) + } else { + when (effect) { + TangemHapticEffect.OneTime.Tick -> perform(TangemHapticEffect.View.SegmentTick) + TangemHapticEffect.OneTime.Click -> perform(TangemHapticEffect.View.ContextClick) + TangemHapticEffect.OneTime.DoubleClick -> perform(TangemHapticEffect.View.ContextClick) + TangemHapticEffect.OneTime.HeavyClick -> perform(TangemHapticEffect.View.LongPress) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt index b93c08bd26..9b0d3cd124 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt @@ -2,12 +2,13 @@ package com.tangem.core.ui.haptic import androidx.compose.runtime.Stable +/** + * Haptic feedback. + * @see [TangemHapticEffect.OneTime] for one-time effects. + * @see [TangemHapticEffect.View] for view effects. + */ @Stable interface HapticManager { - fun vibrateShort() - - fun vibrateMeduim() - - fun vibrateLong() + fun perform(effect: TangemHapticEffect) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt deleted file mode 100644 index ad74ee1bd1..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.core.ui.haptic - -import androidx.compose.ui.hapticfeedback.HapticFeedback -import androidx.compose.ui.hapticfeedback.HapticFeedbackType - -@Suppress("FunctionName") -fun MockHapticManager(mockHapticFeedback: HapticFeedback? = null): HapticManager = - if (mockHapticFeedback == null) MockHapticManager else MockHapticManagerImpl(mockHapticFeedback) - -val MockHapticManager: HapticManager = MockHapticManagerImpl() - -private class MockHapticManagerImpl( - private val mockHapticFeedback: HapticFeedback? = null, -) : HapticManager { - - override fun vibrateShort() { - mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove) - /** Intentionally do nothing */ - } - - override fun vibrateMeduim() { - mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove) - /** Intentionally do nothing */ - } - - override fun vibrateLong() { - mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.LongPress) - /** Intentionally do nothing */ - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/TangemHapticEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/TangemHapticEffect.kt new file mode 100644 index 0000000000..4be47d804d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/TangemHapticEffect.kt @@ -0,0 +1,125 @@ +package com.tangem.core.ui.haptic + +import android.os.Build +import android.os.VibrationEffect +import androidx.annotation.RequiresApi + +sealed interface TangemHapticEffect { + + /** + * For cases when view could not be visible on screen (ex. Activity is not in foreground) + * or there is no view context (ex. background service, Model, ViewModel) + */ + enum class OneTime : TangemHapticEffect { + Click, + DoubleClick, + HeavyClick, + Tick, + ; + + val code: Int + @RequiresApi(Build.VERSION_CODES.Q) + get() = when (this) { + Click -> VibrationEffect.EFFECT_CLICK + DoubleClick -> VibrationEffect.EFFECT_DOUBLE_CLICK + HeavyClick -> VibrationEffect.EFFECT_HEAVY_CLICK + Tick -> VibrationEffect.EFFECT_TICK + } + } + + /** + * Preferred way to provide haptic feedback for UI components + * @see [androidx.core.view.HapticFeedbackConstantsCompat] + */ + @Suppress("MagicNumber") + enum class View(internal val androidHapticFeedbackCode: Int? = null) : TangemHapticEffect { + /** + * The user has performed a long press on an object that is resulting in an action being + * performed + */ + LongPress(0), + /** + * The user has pressed on a virtual on-screen key + */ + VirtualKey(1), + /** + * The user has pressed either an hour or minute tick of a Clock + */ + ClockTick(4), + /** + * The user has performed a context click on an object + */ + ContextClick(6), + /** + * The user has pressed a virtual or software keyboard key + */ + KeyboardPress(3), + /** + * The user has released a virtual keyboard key + */ + KeyboardRelease(7), + /** + * The user has released a virtual key + */ + VirtualKeyRelease(8), + /** + * The user has performed a selection/insertion handle move on text field + */ + TextHandleMove(9), + /** + * The user has started a gesture (e.g. on the soft keyboard) + */ + GestureStart(12), + /** + * The user has finished a gesture (e.g. on the soft keyboard) + */ + GestureEnd(13), + /** + * A haptic effect to signal the confirmation or successful completion of a user interaction + */ + Confirm(16), + /** + * A haptic effect to signal the rejection or failure of a user interaction + */ + Reject(17), + /** + * The user has toggled a switch or button into the on position + */ + ToggleOn(21), + /** + * The user has toggled a switch or button into the off position + */ + ToggleOff(22), + /** + * The user is executing a swipe/drag-style gesture, such as pull-to-refresh, where the + * gesture action is “eligible” at a certain threshold of movement, and can be cancelled by + * moving back past the threshold. This constant indicates that the user's motion has just + * passed the threshold for the action to be activated on release + */ + GestureThresholdActivate(23), + /** + * The user is executing a swipe/drag-style gesture, such as pull-to-refresh, where the + * gesture action is “eligible” at a certain threshold of movement, and can be cancelled by + * moving back past the threshold. This constant indicates that the user's motion has just + * re-crossed back "under" the threshold for the action to be activated, meaning the gesture is + * currently in a cancelled state + */ + GestureThresholdDeactivate(24), + /** + * The user has started a drag-and-drop gesture. The drag target has just been "picked up" + */ + DragStart(25), + /** + * The user is switching between a series of potential choices, for example items in a list + * or discrete points on a slider + */ + SegmentTick(26), + /** + * The user is switching between a series of many potential choices, for example minutes on a + * clock face, or individual percentages. This constant is expected to be very soft, so as + * not to be uncomfortable when performed a lot in quick succession. If the device can’t make + * a suitably soft vibration, then it may not make any vibration + */ + SegmentFrequentTick(27), + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/VibratorHapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/VibratorHapticManager.kt new file mode 100644 index 0000000000..f152a10aca --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/VibratorHapticManager.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.haptic + +import androidx.compose.runtime.Stable + +/** + * Haptic feedback + * For cases when view could not be visible on screen (ex. Activity is not in foreground) + * or there is no view context (ex. background service, Model, ViewModel) + * @see [HapticManager] for view effects. + */ +@Stable +interface VibratorHapticManager { + + fun performOneTime(effect: TangemHapticEffect.OneTime) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt new file mode 100644 index 0000000000..2e81db1003 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt @@ -0,0 +1,26 @@ +package com.tangem.core.ui.res + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.runtime.* + +@Immutable +object TangemAnimations { + + val transitionSpecs = TransitionSpecs + + @Composable + @NonRestartableComposable + fun horizontalIndicatorAsState(targetFraction: Float): State { + return animateFloatAsState( + targetValue = targetFraction, + animationSpec = tween(durationMillis = 300), + label = "Indicator fraction", + ) + } + + @Immutable + object TransitionSpecs { + // TODO add more transition specs + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 300231f9c3..999276faf5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -8,10 +8,12 @@ import androidx.compose.material.ProvideTextStyle import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.components.TangemShimmer +import com.tangem.core.ui.haptic.DefaultHapticManager import com.tangem.core.ui.haptic.HapticManager -import com.tangem.core.ui.haptic.MockHapticManager +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.windowsize.WindowSize import com.valentinilk.shimmer.Shimmer @@ -21,7 +23,7 @@ fun TangemTheme( windowSize: WindowSize, typography: TangemTypography = TangemTheme.typography, dimens: TangemDimens = TangemTheme.dimens, - hapticManager: HapticManager = MockHapticManager, + vibratorHapticManager: VibratorHapticManager? = null, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable () -> Unit, ) { @@ -40,6 +42,12 @@ fun TangemTheme( ) } + val view = LocalView.current + + val hapticManager = remember(view) { + DefaultHapticManager(view = view, vibratorHapticManager = vibratorHapticManager) + } + MaterialTheme( colors = materialThemeColors(colors = themeColors, isDark = isDark), ) { @@ -52,10 +60,11 @@ fun TangemTheme( LocalHapticManager provides hapticManager, LocalSnackbarHostState provides snackbarHostState, LocalWindowSize provides windowSize, - LocalTextSelectionColors provides TangemTextSelectionColors, ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, + LocalMainBottomSheetColor provides remember { mutableStateOf(Color.Unspecified) }, + LocalTextSelectionColors provides TangemTextSelectionColors, ) { ProvideTextStyle( value = TangemTheme.typography.body1, @@ -208,11 +217,13 @@ private fun darkThemeColors(): TangemColors { ) } -@Stable -private val TangemTextSelectionColors = TextSelectionColors( - handleColor = TangemColorPalette.Azure, - backgroundColor = TangemColorPalette.Azure.copy(alpha = 0.4f), -) +private val TangemTextSelectionColors: TextSelectionColors + @Composable + @ReadOnlyComposable + get() = TextSelectionColors( + handleColor = TangemTheme.colors.text.accent, + backgroundColor = TangemTheme.colors.text.accent.copy(alpha = 0.3f), + ) private val LocalTangemColors = staticCompositionLocalOf { error("No TangemColors provided") @@ -246,4 +257,8 @@ val LocalWindowSize = staticCompositionLocalOf { val LocalTangemShimmer = staticCompositionLocalOf { error("No TangemShimmer provided") +} + +val LocalMainBottomSheetColor = staticCompositionLocalOf> { + error("No MainBottomSheetColor provided") } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt index c3ed096dce..0347ca4893 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt @@ -6,8 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.platform.LocalHapticFeedback -import com.tangem.core.ui.haptic.MockHapticManager import com.tangem.core.ui.windowsize.rememberWindowSizePreview @Composable @@ -29,7 +27,6 @@ fun TangemThemePreview( typography = typography, dimens = dimens, windowSize = rememberWindowSizePreview(maxWidth, maxHeight), - hapticManager = MockHapticManager(LocalHapticFeedback.current), content = content, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt b/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt index d6b1869ec5..5557002b53 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt @@ -61,7 +61,7 @@ internal fun ComposeScreen.createComposeView(context: Context, activity: Activit TangemTheme( isDark = shouldUseDarkTheme(appThemeMode), windowSize = windowSize, - hapticManager = uiDependencies.hapticManager, + vibratorHapticManager = uiDependencies.vibratorHapticManager, snackbarHostState = uiDependencies.globalSnackbarHostState, ) { ScreenContent(modifier = screenModifier) diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt index e5d94f85b7..91ef672575 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt @@ -1,7 +1,6 @@ package com.tangem.core.ui.utils import android.icu.text.CompactDecimalFormat -import android.os.Build import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.LOWER_SIGN @@ -13,18 +12,28 @@ import java.text.NumberFormat import java.util.Currency import java.util.Locale +@Suppress("LargeClass") object BigDecimalFormatter { const val EMPTY_BALANCE_SIGN = DASH_SIGN - const val CAN_BE_LOWER_SIGN = LOWER_SIGN + private const val CAN_BE_LOWER_SIGN = LOWER_SIGN private val FORMAT_THRESHOLD = BigDecimal("0.01") private const val TEMP_CURRENCY_CODE = "USD" private val FIAT_FORMAT_THRESHOLD = BigDecimal("0.01") + private val CRYPTO_FEE_FORMAT_THRESHOLD = BigDecimal("0.000001") + private const val FIAT_MARKET_DEFAULT_DIGITS = 2 private const val FIAT_MARKET_EXTENDED_DIGITS = 6 + private val bigDecimal01 = BigDecimal("0.1") + private val bigDecimal001 = BigDecimal("0.01") + private val bigDecimal0001 = BigDecimal("0.001") + private val bigDecimal00001 = BigDecimal("0.0001") + private val bigDecimal000001 = BigDecimal("0.00001") + private val bigDecimal0000001 = BigDecimal("0.000001") + fun formatCryptoAmount( cryptoAmount: BigDecimal?, cryptoCurrency: String, @@ -105,6 +114,45 @@ object BigDecimalFormatter { } } + fun formatCryptoFeeAmount( + cryptoAmount: BigDecimal?, + cryptoCurrency: String, + decimals: Int, + canBeLower: Boolean = false, + locale: Locale = Locale.getDefault(), + ): String { + if (cryptoAmount == null) return EMPTY_BALANCE_SIGN + + val formatter = NumberFormat.getNumberInstance(locale).apply { + maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6) + minimumFractionDigits = 2 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + + val amountFormatted = if (cryptoAmount.checkCryptoThreshold()) { + buildString { + append(CAN_BE_LOWER_SIGN) + append( + formatter.format(CRYPTO_FEE_FORMAT_THRESHOLD), + ) + } + } else { + buildString { + if (canBeLower) { + append(CAN_BE_LOWER_SIGN) + } + append(formatter.format(cryptoAmount)) + } + } + + return if (cryptoCurrency.isEmpty()) { + amountFormatted + } else { + amountFormatted + "\u2009$cryptoCurrency" + } + } + fun formatCryptoAmount( cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency, @@ -117,6 +165,7 @@ object BigDecimalFormatter { fiatAmount: BigDecimal?, fiatCurrencyCode: String, fiatCurrencySymbol: String, + decimals: Int = FIAT_MARKET_DEFAULT_DIGITS, locale: Locale = Locale.getDefault(), ): String { if (fiatAmount == null) return EMPTY_BALANCE_SIGN @@ -124,12 +173,12 @@ object BigDecimalFormatter { val formatterCurrency = getCurrency(fiatCurrencyCode) val formatter = NumberFormat.getCurrencyInstance(locale).apply { currency = formatterCurrency - maximumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS - minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + maximumFractionDigits = decimals + minimumFractionDigits = decimals roundingMode = RoundingMode.HALF_UP } - return if (fiatAmount.isLessThanThreshold()) { + return if (fiatAmount.checkFiatThreshold()) { buildString { append(CAN_BE_LOWER_SIGN) append( @@ -152,7 +201,7 @@ object BigDecimalFormatter { if (fiatAmount == null) return EMPTY_BALANCE_SIGN val formatterCurrency = getCurrency(fiatCurrencyCode) - val digits = if (fiatAmount.isLessThanThreshold()) { + val digits = if (fiatAmount.checkFiatThreshold()) { FIAT_MARKET_EXTENDED_DIGITS } else { FIAT_MARKET_DEFAULT_DIGITS @@ -168,6 +217,28 @@ object BigDecimalFormatter { .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } + fun formatFiatPriceUncapped( + fiatAmount: BigDecimal?, + fiatCurrencyCode: String, + fiatCurrencySymbol: String, + locale: Locale = Locale.getDefault(), + ): String { + if (fiatAmount == null) return EMPTY_BALANCE_SIGN + val formatterCurrency = getCurrency(fiatCurrencyCode) + + val decimals = getProperFiatPriceDecimals(fiatAmount) + + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + maximumFractionDigits = decimals + minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + roundingMode = RoundingMode.HALF_UP + } + + return formatter.format(fiatAmount) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) + } + fun formatFiatEditableAmount( fiatAmount: String?, fiatCurrencyCode: String, @@ -250,40 +321,27 @@ object BigDecimalFormatter { /** * "123456.6" -> "$123.457K" * "12345.6" -> "$123.046K" + * Negative amount is not supported + * @param threeDigitsMethod if true, will format the amount always with 3 significant digits + * @param scale the number of digits to the right of the decimal point */ @Suppress("MagicNumber") - fun formatCompactAmount( - amount: BigDecimal, + fun formatCompactFiatAmount( + amount: BigDecimal?, fiatCurrencyCode: String, fiatCurrencySymbol: String, + threeDigitsMethod: Boolean = false, + scale: Int = 0, locale: Locale = Locale.getDefault(), ): String { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - return BigDecimalFormatterCompat.formatCompactAmountNoLocaleContext( - amount = amount, - fiatCurrencyCode = fiatCurrencyCode, - fiatCurrencySymbol = fiatCurrencySymbol, - locale = locale, - ) - } + if (amount == null) return EMPTY_BALANCE_SIGN - val scaledAmount = amount.setScale(0, RoundingMode.HALF_UP) - val digitsCount = scaledAmount.longValueExact().toString().count() - val digitsToFormat = 6 - when (digitsCount % 3) { - 0 -> 0 - 1 -> 2 - else -> 1 - } - - val formatter = CompactDecimalFormat.getInstance( - locale, - CompactDecimalFormat.CompactStyle.SHORT, - ).apply { - minimumSignificantDigits = 4 - maximumSignificantDigits = digitsToFormat - } - - val rawAmount = formatter.format(amount.setScale(0, RoundingMode.HALF_UP)) + val rawAmount = formatCompactAmount( + amount = amount, + locale = locale, + threeDigitsMethod = threeDigitsMethod, + scale = scale, + ) return addCurrencySymbolToStringAmount( amount = rawAmount, @@ -293,5 +351,65 @@ object BigDecimalFormatter { ) } - private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD + /** + * "123456.6" -> "123.457K" + * "12345.6" -> "123.046K" + * Negative amount is not supported + * @param threeDigitsMethod if true, will format the amount always with 3 significant digits + * @param scale the number of digits to the right of the decimal point + */ + @Suppress("MagicNumber") + fun formatCompactAmount( + amount: BigDecimal, + locale: Locale = Locale.getDefault(), + threeDigitsMethod: Boolean = false, + scale: Int = 0, + ): String { + if (threeDigitsMethod) { + val scaledAmount = amount.setScale(scale, RoundingMode.HALF_UP) + val digitsCount = scaledAmount.longValueExact().toString().count() + val digitsToFormat = 6 - when (digitsCount % 3) { + 0 -> 0 + 1 -> 2 + else -> 1 + } + + val formatter = CompactDecimalFormat.getInstance( + locale, + CompactDecimalFormat.CompactStyle.SHORT, + ).apply { + minimumSignificantDigits = 4 + maximumSignificantDigits = digitsToFormat + } + + return formatter.format(amount.setScale(scale, RoundingMode.HALF_UP)) + } else { + val value = amount.setScale(scale, RoundingMode.HALF_UP) + + val formatter = CompactDecimalFormat.getInstance( + locale, + CompactDecimalFormat.CompactStyle.SHORT, + ) + + return formatter.format(value) + } + } + + private fun BigDecimal.checkFiatThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD + + private fun BigDecimal.checkCryptoThreshold() = this > BigDecimal.ZERO && this < CRYPTO_FEE_FORMAT_THRESHOLD + + @Suppress("MagicNumber") + fun getProperFiatPriceDecimals(price: BigDecimal): Int { + return when { + price >= BigDecimal.ONE -> 2 + price >= bigDecimal01 -> 3 + price >= bigDecimal001 -> 4 + price >= bigDecimal0001 -> 6 + price >= bigDecimal00001 -> 8 + price >= bigDecimal000001 -> 10 + price >= bigDecimal0000001 -> 12 + else -> price.stripTrailingZeros().scale() + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt index 82aab46559..9218188bef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt @@ -7,16 +7,32 @@ import java.util.Locale internal object BigDecimalFormatterCompat { /** - * Formats value as [BigDecimalFormatter.formatCompactAmount] does using only "T","B","M","K" suffixes + * Formats value as [BigDecimalFormatter.formatCompactFiatAmount] does using only "T","B","M","K" suffixes * Used for < API24 compatibility */ @Suppress("MagicNumber", "UnnecessaryParentheses") - fun formatCompactAmountNoLocaleContext( + fun formatCompactFiatAmountNoLocaleContext( amount: BigDecimal, fiatCurrencyCode: String, fiatCurrencySymbol: String, locale: Locale = Locale.getDefault(), ): String { + val formatted = formatCompactAmountNoLocaleContext(amount) + + return BigDecimalFormatter.addCurrencySymbolToStringAmount( + amount = formatted, + fiatCurrencyCode = fiatCurrencyCode, + fiatCurrencySymbol = fiatCurrencySymbol, + locale = locale, + ) + } + + /** + * Formats value as [BigDecimalFormatter.formatCompactAmount] does using only "T","B","M","K" suffixes + * Used for < API24 compatibility + */ + @Suppress("MagicNumber", "UnnecessaryParentheses") + fun formatCompactAmountNoLocaleContext(amount: BigDecimal): String { val value = amount.setScale(0, RoundingMode.HALF_UP).longValueExact() val formatted = when { @@ -42,11 +58,6 @@ internal object BigDecimalFormatterCompat { else -> return value.toString() } - return BigDecimalFormatter.addCurrencySymbolToStringAmount( - amount = formatted, - fiatCurrencyCode = fiatCurrencyCode, - fiatCurrencySymbol = fiatCurrencySymbol, - locale = locale, - ) + return formatted } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index d42121d3f0..4dd1e38fd1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -10,15 +10,22 @@ import java.util.Locale @Suppress("MagicNumber") object DateTimeFormatters { - private const val DDMMYYYY = "dd.MM.yyyy" + /** + * Determine if the time is in 12-hour format ("10:00 PM") for the current locale. + */ + val is12HourFormat by lazy { + /** + * Two SS means, SHORT style for date and time. + * If pattern contains "a", it means time is in 12 hour format. + * [Documentation](https://www.joda.org/joda-time/apidocs/org/joda/time/format/DateTimeFormat.html) + */ + DateTimeFormat.patternForStyle("SS", Locale.getDefault()).contains("a") + } /** - * Two SS means, SHORT style for date and time. - * If pattern contains "a", it means time is in 12 hour format. - * [Documentation](https://www.joda.org/joda-time/apidocs/org/joda/time/format/DateTimeFormat.html) + * Example: "12:00 PM", "12:00" */ val timeFormatter: DateTimeFormatter by lazy { - val is12HourFormat = DateTimeFormat.patternForStyle("SS", Locale.getDefault()).contains("a") if (is12HourFormat) { DateTimeFormatterBuilder() .appendClockhourOfHalfday(1) @@ -38,6 +45,9 @@ object DateTimeFormatters { } } + /** + * Example: "1 Jun, 2020", "1 Jun, 2020" + */ val dateFormatter: DateTimeFormatter by lazy { DateTimeFormatterBuilder() .appendDayOfMonth(1) @@ -49,28 +59,61 @@ object DateTimeFormatters { .withLocale(Locale.getDefault()) } + /** + * Example: "31.06.2020", "06/31/2020" + */ val dateDDMMYYYY: DateTimeFormatter by lazy { - DateTimeFormatterBuilder() - .appendPattern(DDMMYYYY) - .toFormatter() - .withLocale(Locale.getDefault()) + getBestFormatterBySkeleton("dd.MM.yyyy") } /** - * In API version < 24, there may be some problems with getting the best date and time format pattern. + * Example: "Jun 31, 2020", "31 Jun, 2020" */ - val dateMMMMd: DateTimeFormatter by lazy { - DateTimeFormatterBuilder() - .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMM d")) - .toFormatter() - .withLocale(Locale.getDefault()) + val dateMMMdd: DateTimeFormatter by lazy { + getBestFormatterBySkeleton("MMM dd") } + /** + * Example: "2020" + */ + val dateYYYY: DateTimeFormatter by lazy { + getBestFormatterBySkeleton("yyyy") + } + + /** + * Example: "31.06.2020 12:00", "06/31/2020 12:00", "06/31/2020 12:00 PM" + */ val dateTimeFormatter: DateTimeFormatter by lazy { - DateTimeFormat.forPattern("dd.MM.yyyy HH:mm") + getBestFormatterBySkeleton("dd.MM.yyyy HH:mm") } fun formatDate(date: DateTime, formatter: DateTimeFormatter = dateFormatter): String { return formatter.print(date) } + + /** + * Returns the best date and time format pattern for the given skeleton and the current locale. + * (In API version < 24, there may be some problems with getting the best date and time format pattern.) + * + * @param skeleton The skeleton is an alternative to the pattern. The difference is that the pattern rigidly + * defines the date/time format, while the skeleton specifies only the date/time components (year, month, day, etc.) + * So the order of the components and separators (space, comma, etc.) is not taken into account. + * @see [dateYYYY], [dateMMMdd], [dateDDMMYYYY], [dateTimeFormatter] + */ + fun getBestFormatterBySkeleton(skeleton: String): DateTimeFormatter { + val skeletonWithLocale = skeleton.replaceHourLetters() + + return DateTimeFormatterBuilder() + .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), skeletonWithLocale)) + .toFormatter() + .withLocale(Locale.getDefault()) + } + + private fun String.replaceHourLetters(): String { + return if (is12HourFormat) { + this.replace('H', 'h').replace('k', 'K') + } else { + this.replace('h', 'H').replace('K', 'k') + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt index bc2560f6e9..e01b50dfb3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt @@ -29,5 +29,12 @@ fun Long.toDateFormatWithTodayYesterday(formatter: DateTimeFormatter = DateTimeF * Returns formatted time according to [formatter]. */ fun Long.toTimeFormat(formatter: DateTimeFormatter = DateTimeFormatters.timeFormatter): String { + return formatAsDateTime(formatter) +} + +/** + * Returns formatted date-time according to [formatter]. + */ +fun Long.formatAsDateTime(formatter: DateTimeFormatter): String { return DateTimeFormatters.formatDate(date = DateTime(this, DateTimeZone.getDefault()), formatter = formatter) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/PreviewUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/PreviewUtils.kt new file mode 100644 index 0000000000..6cb1f784b9 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/PreviewUtils.kt @@ -0,0 +1,55 @@ +package com.tangem.core.ui.utils + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.coroutines.delay + +/** + * A container that shows a shimmer effect on top of the actual content. + * The shimmer effect is toggled every 2 seconds. + * Used for previewing components with shimmer effect and comparing their sizes with the actual content. + * + * If height is changing during the preview, it means that the actual content is not aligned with the shimmer effect. + * + * @param actualContent The actual content to be displayed. + * @param shimmerContent The shimmer effect to be displayed. + */ +@Composable +fun PreviewShimmerContainer(actualContent: @Composable () -> Unit, shimmerContent: @Composable () -> Unit) { + Column { + var height by remember { mutableIntStateOf(0) } + Row { + Text("height = $height") + } + SpacerH4() + + var shimmerVisible by remember { mutableStateOf(true) } + + TangemThemePreview { + Box( + Modifier.onGloballyPositioned { + height = it.size.height + }, + ) { + actualContent() + if (shimmerVisible) { + shimmerContent() + } + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(timeMillis = 2000) + shimmerVisible = !shimmerVisible + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt new file mode 100644 index 0000000000..3cef274d3a --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll + +fun Modifier.disableNestedScroll(): Modifier = nestedScroll(DisableParentConnection) + +private object DisableParentConnection : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + return available.copy(x = 0f) + } +} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_blast_22.xml b/core/ui/src/main/res/drawable/ic_blast_22.xml new file mode 100644 index 0000000000..fea379b692 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_blast_22.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_chevron_right_24.xml b/core/ui/src/main/res/drawable/ic_chevron_right_24.xml index 1bb30766a3..963e79f50b 100644 --- a/core/ui/src/main/res/drawable/ic_chevron_right_24.xml +++ b/core/ui/src/main/res/drawable/ic_chevron_right_24.xml @@ -1,10 +1,5 @@ - - + + + + diff --git a/core/ui/src/main/res/drawable/ic_cyber_22.xml b/core/ui/src/main/res/drawable/ic_cyber_22.xml new file mode 100644 index 0000000000..b622330ee9 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_cyber_22.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_filecoin_22.xml b/core/ui/src/main/res/drawable/ic_filecoin_22.xml new file mode 100644 index 0000000000..3c71f2b663 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_filecoin_22.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/img_blast_22.xml b/core/ui/src/main/res/drawable/img_blast_22.xml new file mode 100644 index 0000000000..515289963d --- /dev/null +++ b/core/ui/src/main/res/drawable/img_blast_22.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/img_cyber_22.xml b/core/ui/src/main/res/drawable/img_cyber_22.xml new file mode 100644 index 0000000000..e6af2f1807 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_cyber_22.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_filecoin_22.xml b/core/ui/src/main/res/drawable/img_filecoin_22.xml new file mode 100644 index 0000000000..aca11bbf42 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_filecoin_22.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_staking_banner.xml b/core/ui/src/main/res/drawable/img_staking_banner.xml new file mode 100644 index 0000000000..61787d856c --- /dev/null +++ b/core/ui/src/main/res/drawable/img_staking_banner.xml @@ -0,0 +1,359 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt b/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt new file mode 100644 index 0000000000..48b2788b37 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt @@ -0,0 +1,35 @@ +package com.tangem.utils + +import java.util.Locale + +object SupportedLanguages { + const val ENGLISH = "en" + const val RUSSIAN = "ru" + const val GERMAN = "de" + const val FRANCH = "fr" + const val ITALIAN = "it" + const val JAPANESE = "ja" + const val UKRAINIAN = "uk" + const val CHINESE = "uk" + + val supportedLangugeCodes = listOf( + ENGLISH, + RUSSIAN, + GERMAN, + FRANCH, + ITALIAN, + JAPANESE, + UKRAINIAN, + CHINESE, + ) + + fun getCurrentSupportedLanguageCode(): String { + val locale = Locale.getDefault() + + return if (supportedLangugeCodes.contains(locale.language)) { + locale.language + } else { + ENGLISH + } + } +} \ No newline at end of file diff --git a/data/analytics/src/main/kotlin/com/tangem/data/analytics/DefaultAnalyticsRepository.kt b/data/analytics/src/main/kotlin/com/tangem/data/analytics/DefaultAnalyticsRepository.kt index 4866aa1c89..b1554751cc 100644 --- a/data/analytics/src/main/kotlin/com/tangem/data/analytics/DefaultAnalyticsRepository.kt +++ b/data/analytics/src/main/kotlin/com/tangem/data/analytics/DefaultAnalyticsRepository.kt @@ -3,7 +3,7 @@ package com.tangem.data.analytics import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectListSync -import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.datasource.local.preferences.utils.getObjectMapSync import com.tangem.domain.analytics.model.WalletBalanceState import com.tangem.domain.analytics.repository.AnalyticsRepository import com.tangem.domain.wallets.models.UserWalletId @@ -29,7 +29,7 @@ internal class DefaultAnalyticsRepository( } override suspend fun getWalletBalanceState(userWalletId: UserWalletId): WalletBalanceState? { - val walletsBalanceState = appPreferencesStore.getObjectMap( + val walletsBalanceState = appPreferencesStore.getObjectMapSync( key = PreferencesKeys.WALLETS_BALANCES_STATES_KEY, ) @@ -41,7 +41,7 @@ internal class DefaultAnalyticsRepository( val walletsBalanceState = it.getObjectMap( key = PreferencesKeys.WALLETS_BALANCES_STATES_KEY, ) - val updatedWalletsBalanceState = walletsBalanceState.orEmpty() + val updatedWalletsBalanceState = walletsBalanceState .plus(pair = userWalletId.stringValue to balanceState) it.setObjectMap(PreferencesKeys.WALLETS_BALANCES_STATES_KEY, updatedWalletsBalanceState) diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index f1a67e77a3..5a5b6c1d85 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -10,13 +10,26 @@ android { } dependencies { + /* Core */ implementation(projects.core.datasource) + /* Domain */ + implementation(projects.domain.models) + implementation(projects.domain.legacy) + implementation(projects.domain.tokens.models) + + /* Libs - SDK */ + implementation(deps.tangem.blockchain) + implementation(deps.tangem.card.core) + implementation(projects.libs.blockchainSdk) + + /* DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /* Libs - Other */ implementation(deps.kotlin.coroutines) implementation(deps.jodatime) implementation(deps.timber) implementation(deps.arrow.core) - - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt index 837e169c8b..3334a3c339 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt @@ -67,7 +67,7 @@ suspend inline fun safeApiCall( ): T = recover( block = { call(ApiResponseRaise(raise = this)) }, recover = { - Timber.w(it, "Unable to perform safe API call") + Timber.e(it, "Unable to perform safe API call") onError(it) }, ) \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt similarity index 99% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CryptoCurrencyFactory.kt rename to data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt index b1e215ed19..9af9e67943 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokens.utils +package com.tangem.data.common.currency import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt similarity index 85% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt rename to data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt index c7986bd414..8f2e5b60ac 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokens.utils +package com.tangem.data.common.currency import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.FeePaidCurrency @@ -7,14 +7,14 @@ import com.tangem.domain.common.DerivationStyleProvider import com.tangem.domain.tokens.model.Network import timber.log.Timber -internal fun getBlockchain(networkId: Network.ID): Blockchain { +fun getBlockchain(networkId: Network.ID): Blockchain { return Blockchain.fromId(networkId.value) } -internal fun getNetwork( +fun getNetwork( blockchain: Blockchain, extraDerivationPath: String?, - derivationStyleProvider: DerivationStyleProvider, + derivationStyleProvider: DerivationStyleProvider?, ): Network? { if (blockchain == Blockchain.Unknown) { Timber.e("Unable to convert Unknown blockchain to the domain network model") @@ -36,8 +36,12 @@ internal fun getNetwork( private fun getNetworkDerivationPath( blockchain: Blockchain, extraDerivationPath: String?, - cardDerivationStyleProvider: DerivationStyleProvider, + cardDerivationStyleProvider: DerivationStyleProvider?, ): Network.DerivationPath { + if (cardDerivationStyleProvider == null) { + return Network.DerivationPath.None + } + val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider) return if (extraDerivationPath.isNullOrBlank()) { @@ -55,7 +59,7 @@ private fun getNetworkDerivationPath( } } -internal fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { +fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { return when (blockchain) { Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.StandardType.ERC20 Blockchain.BSC, Blockchain.BSCTestnet -> Network.StandardType.BEP20 diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt similarity index 98% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt rename to data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt index 4cc79ed407..9bbdfb7fed 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokens.utils +package com.tangem.data.common.currency import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token @@ -106,6 +106,7 @@ class ResponseCryptoCurrenciesFactory { Blockchain.Telos, Blockchain.Cronos, Blockchain.TON, + Blockchain.Cyber, -> this.fullName else -> responseToken.name } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/TokensOperations.kt similarity index 78% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt rename to data/common/src/main/kotlin/com/tangem/data/common/currency/TokensOperations.kt index a944703601..72018fc4ed 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/TokensOperations.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokens.utils +package com.tangem.data.common.currency import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.IconsUtil @@ -18,30 +18,35 @@ private const val DEFAULT_TOKENS_ICONS_HOST = "https://s3.eu-central-1.amazonaws private const val TOKEN_ICON_SIZE = "large" private const val TOKEN_ICON_EXT = "png" -internal fun isCustomToken(tokenId: ID, network: Network): Boolean { +fun isCustomToken(tokenId: ID, network: Network): Boolean { return network.derivationPath is Network.DerivationPath.Custom || tokenId.rawCurrencyId == null } -internal fun isCustomCoin(network: Network): Boolean { +fun isCustomCoin(network: Network): Boolean { return network.derivationPath is Network.DerivationPath.Custom } -internal fun getCoinId(network: Network, coinId: String): ID { +fun getCoinId(network: Network, coinId: String): ID { return ID(COIN_ID_PREFIX, getCurrencyIdBody(network), CurrencyIdSuffix(rawId = coinId)) } -internal fun getTokenId(network: Network, sdkToken: SdkToken): ID { +fun getTokenId(network: Network, sdkToken: SdkToken): ID { val sdkTokenId = sdkToken.id - val suffix = if (sdkTokenId == null) { - CustomCurrencyIdSuffix(contractAddress = sdkToken.contractAddress) + + return getTokenId(network, sdkTokenId, sdkToken.contractAddress) +} + +fun getTokenId(network: Network, rawTokenId: String?, contractAddress: String): ID { + val suffix = if (rawTokenId == null) { + CustomCurrencyIdSuffix(contractAddress) } else { - CurrencyIdSuffix(rawId = sdkTokenId, contractAddress = sdkToken.contractAddress) + CurrencyIdSuffix(rawTokenId, contractAddress) } return ID(TOKEN_ID_PREFIX, getCurrencyIdBody(network), suffix) } -internal fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? { +fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? { val tokenId = token.id return if (tokenId == null) { @@ -51,7 +56,7 @@ internal fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? { } } -internal fun getCoinIconUrl(blockchain: Blockchain): String? { +fun getCoinIconUrl(blockchain: Blockchain): String? { val coinId = when (blockchain) { Blockchain.Unknown -> null else -> blockchain.toCoinId() @@ -60,7 +65,7 @@ internal fun getCoinIconUrl(blockchain: Blockchain): String? { return coinId?.let(::getTokenIconUrlFromDefaultHost) } -internal fun List.hasCoinForToken(token: CryptoCurrency.Token): Boolean { +fun List.hasCoinForToken(token: CryptoCurrency.Token): Boolean { return any { val blockchain = getBlockchain(networkId = token.network.id) val tokenDerivation = token.network.derivationPath.value diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt similarity index 97% rename from data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/UserTokensResponseFactory.kt rename to data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index 7e8372da6c..4ba8bbd2a2 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokens.utils +package com.tangem.data.common.currency import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.datasource.api.tangemTech.models.UserTokensResponse diff --git a/data/markets/src/main/java/com/tangem/data/markets/utils/RequestUtils.kt b/data/common/src/main/kotlin/com/tangem/data/common/utils/RequestUtils.kt similarity index 77% rename from data/markets/src/main/java/com/tangem/data/markets/utils/RequestUtils.kt rename to data/common/src/main/kotlin/com/tangem/data/common/utils/RequestUtils.kt index 18f4e1fda0..e7f3fbfe45 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/utils/RequestUtils.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/utils/RequestUtils.kt @@ -1,4 +1,4 @@ -package com.tangem.data.markets.utils +package com.tangem.data.common.utils import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -8,7 +8,7 @@ import timber.log.Timber import kotlin.coroutines.cancellation.CancellationException @Suppress("UnconditionalJumpStatementInLoop") -internal suspend fun retryOnError(priority: Boolean = false, call: suspend () -> T): T { +suspend fun retryOnError(priority: Boolean = false, call: suspend () -> T): T { while (true) { return try { call() @@ -16,11 +16,14 @@ internal suspend fun retryOnError(priority: Boolean = false, call: suspend ( if (e is CancellationException) { currentCoroutineContext().ensureActive() } - Timber.e(e) + + Timber.e(e, "Error occurred during retryOnError block") + if (priority.not()) { yield() delay(timeMillis = 500) } + continue } } 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 b5313ac608..26278b5877 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 @@ -8,7 +8,7 @@ import com.tangem.data.feedback.converters.BlockchainInfoConverter import com.tangem.data.feedback.converters.CardInfoConverter import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.datasource.local.preferences.utils.getObjectMapSync import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.repository.FeedbackRepository @@ -99,7 +99,7 @@ internal class DefaultFeedbackRepository( } override suspend fun getAppLogs(): List { - return appPreferencesStore.getObjectMap(key = PreferencesKeys.APP_LOGS_KEY) + return appPreferencesStore.getObjectMapSync(key = PreferencesKeys.APP_LOGS_KEY) .map { AppLogModel(timestamp = it.key.toLong(), message = it.value) } .sortedBy(AppLogModel::timestamp) } diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/BlockchainInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/BlockchainInfoConverter.kt index de7f9b6d04..686cec71dd 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/converters/BlockchainInfoConverter.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/converters/BlockchainInfoConverter.kt @@ -24,7 +24,12 @@ internal object BlockchainInfoConverter : Converter - BlockchainInfo.TokenInfo(id = token.id, name = token.name, contractAddress = token.contractAddress) + BlockchainInfo.TokenInfo( + id = token.id, + name = token.name, + contractAddress = token.contractAddress, + decimals = token.decimals.toString(), + ) }, ) } diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt index c4aafd2bf2..cb2081979e 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt @@ -20,6 +20,10 @@ internal object CardInfoConverter : Converter { CardInfo( userWalletId = createUserWalletId(scanResponse = value), cardId = card.cardId, + cardsCount = when (val status = value.card.backupStatus) { + is CardDTO.BackupStatus.Active -> status.cardCount.toString() + else -> "0" + }, firmwareVersion = card.firmwareVersion.stringValue, cardBlockchain = walletData?.blockchain, signedHashesList = card.wallets.map { diff --git a/data/manage-tokens/.gitignore b/data/manage-tokens/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/manage-tokens/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/manage-tokens/build.gradle.kts b/data/manage-tokens/build.gradle.kts new file mode 100644 index 0000000000..137a893c81 --- /dev/null +++ b/data/manage-tokens/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.example.data.managetokens" +} + +dependencies { + + /** Project - Domain */ + implementation(projects.domain.models) + implementation(projects.domain.manageTokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets.models) + + /** Project - Data */ + implementation(projects.core.datasource) + implementation(projects.data.common) + + /** Project - Utils */ + implementation(projects.core.utils) + implementation(projects.core.pagination) + implementation(projects.domain.legacy) + implementation(projects.libs.blockchainSdk) + + /** Tangem SDKs */ + implementation(deps.tangem.blockchain) + implementation(deps.tangem.card.core) + + /** AndroidX */ + implementation(deps.androidx.datastore) + + /** DI */ + implementation(deps.hilt.core) + kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.moshi.kotlin) + implementation(deps.timber) +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt new file mode 100644 index 0000000000..bb45239024 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultManageTokensRepository.kt @@ -0,0 +1,138 @@ +package com.tangem.data.managetokens + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.isSupportedInApp +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.data.common.utils.retryOnError +import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher +import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.tangemTech.TangemTechApi +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.getObjectSyncOrNull +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.common.extensions.supportedBlockchains +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.common.util.derivationStyleProvider +import com.tangem.domain.managetokens.model.ManageTokensListBatchFlow +import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext +import com.tangem.domain.managetokens.model.ManageTokensListConfig +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.repository.ManageTokensRepository +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.BatchListSource +import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher +import com.tangem.pagination.toBatchFlow +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +internal class DefaultManageTokensRepository( + private val tangemTechApi: TangemTechApi, + private val userWalletsStore: UserWalletsStore, + private val managedCryptoCurrencyFactory: ManagedCryptoCurrencyFactory, + private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : ManageTokensRepository { + + override fun getTokenListBatchFlow( + context: ManageTokensListBatchingContext, + batchSize: Int, + ): ManageTokensListBatchFlow { + return BatchListSource( + fetchDispatcher = dispatchers.io, + context = context, + generateNewKey = { it.size.inc() }, + batchFetcher = createFetcher(batchSize), + updateFetcher = manageTokensUpdateFetcher, + ).toBatchFlow() + } + + @Suppress("ComplexCondition") + private fun createFetcher( + batchSize: Int, + ): LimitOffsetBatchFetcher> = LimitOffsetBatchFetcher( + prefetchDistance = batchSize, + batchSize = batchSize, + subFetcher = { request, _, isFirstBatchFetching -> + val userWallet = getUserWallet(request.params.userWalletId) + val supportedBlockchains = getSupportedBlockchains(userWallet) + val searchText = request.params.searchText?.takeIf { it.isNotBlank() } + + val call = suspend { + tangemTechApi.getCoins( + networkIds = supportedBlockchains.joinToString( + separator = ",", + transform = Blockchain::toNetworkId, + ), + active = true, + searchText = searchText, + offset = request.offset * request.limit, + limit = request.limit, + ).getOrThrow() + } + + val coinsResponse = if (isFirstBatchFetching) { + call() + } else { + retryOnError(call = call) + } + + val tokensResponse = getStoredUserTokens(request.params.userWalletId) + val items = if (isFirstBatchFetching && + tokensResponse != null && + userWallet != null && + request.params.searchText.isNullOrBlank() + ) { + managedCryptoCurrencyFactory.createWithCustomTokens( + coinsResponse = coinsResponse, + tokensResponse = tokensResponse, + derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, + ) + } else { + managedCryptoCurrencyFactory.create( + coinsResponse = coinsResponse, + tokensResponse = tokensResponse, + derivationStyleProvider = userWallet?.scanResponse?.derivationStyleProvider, + ) + } + + BatchFetchResult.Success( + data = items, + empty = items.isEmpty(), + last = items.size < request.limit, + ) + }, + ) + + private suspend fun getStoredUserTokens(userWalletId: UserWalletId?): UserTokensResponse? { + return if (userWalletId != null) { + appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue), + ) + } else { + null + } + } + + private suspend fun getUserWallet(userWalletId: UserWalletId?): UserWallet? { + if (userWalletId == null) { + return null + } + + return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { + "User wallet not found" + } + } + + private fun getSupportedBlockchains(userWallet: UserWallet?): List { + return userWallet?.scanResponse?.let { + it.card.supportedBlockchains(it.cardTypesResolver) + } ?: Blockchain.entries.filter { + !it.isTestnet() && it.isSupportedInApp() + } + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt new file mode 100644 index 0000000000..5cb10e4936 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt @@ -0,0 +1,40 @@ +package com.tangem.data.managetokens.di + +import com.tangem.data.managetokens.DefaultManageTokensRepository +import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher +import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.managetokens.repository.ManageTokensRepository +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 ManageTokensDataModule { + + @Provides + @Singleton + fun provideManageTokensRepository( + tangemTechApi: TangemTechApi, + userWalletsStore: UserWalletsStore, + managedCryptoCurrencyFactory: ManagedCryptoCurrencyFactory, + manageTokensUpdateFetcher: ManageTokensUpdateFetcher, + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): ManageTokensRepository { + return DefaultManageTokensRepository( + tangemTechApi, + userWalletsStore, + managedCryptoCurrencyFactory, + manageTokensUpdateFetcher, + appPreferencesStore, + dispatchers, + ) + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt new file mode 100644 index 0000000000..6c23f37b0b --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManageTokensUpdateFetcher.kt @@ -0,0 +1,68 @@ +package com.tangem.data.managetokens.utils + +import com.tangem.domain.managetokens.model.ManageTokensUpdateAction +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchUpdateFetcher +import com.tangem.pagination.BatchUpdateResult +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ManageTokensUpdateFetcher @Inject constructor() : + BatchUpdateFetcher, ManageTokensUpdateAction> { + + override suspend fun BatchUpdateFetcher.UpdateContext>.fetchUpdateAsync( + toUpdate: List>>, + updateRequest: ManageTokensUpdateAction, + ) { + when (updateRequest) { + is ManageTokensUpdateAction.AddCurrency -> coroutineScope { + val tasks = toUpdate.map { (key, data) -> + async { + val currencyIndex = data.indexOfFirst { it.id == updateRequest.currencyId } + .takeIf { it != -1 } + ?: error("Currency '${updateRequest.currencyId}' not found in batch #$key") + val updatedCurrency = when (val currency = data[currencyIndex]) { + is ManagedCryptoCurrency.Custom -> error("Can't add custom currency '${currency.id}'") + is ManagedCryptoCurrency.Token -> { + currency.copy( + addedIn = if (updateRequest.isSelected) { + currency.addedIn + updateRequest.networkId + } else { + currency.addedIn - updateRequest.networkId + }, + ) + } + } + + data.toMutableList().apply { + set(currencyIndex, updatedCurrency) + } + } + } + + tasks.forEachIndexed { index, task -> + launch { + val updatedItems = task.await() + + update { + BatchUpdateResult.Success( + data = mapNotNull { + if (it.key == toUpdate[index].key) { + Batch(it.key, updatedItems) + } else { + null + } + }, + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt new file mode 100644 index 0000000000..de239a1f09 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/ManagedCryptoCurrencyFactory.kt @@ -0,0 +1,168 @@ +package com.tangem.data.managetokens.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.isSupportedInApp +import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.data.common.currency.getCoinId +import com.tangem.data.common.currency.getNetwork +import com.tangem.data.common.currency.getTokenId +import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +import com.tangem.domain.common.DerivationStyleProvider +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork +import com.tangem.domain.tokens.model.Network +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ManagedCryptoCurrencyFactory @Inject constructor() { + + fun create( + coinsResponse: CoinsResponse, + tokensResponse: UserTokensResponse?, + derivationStyleProvider: DerivationStyleProvider?, + ): List { + return coinsResponse.coins.mapNotNull { coin -> + createToken(coin, tokensResponse, coinsResponse.imageHost, derivationStyleProvider) + } + } + + fun createWithCustomTokens( + coinsResponse: CoinsResponse, + tokensResponse: UserTokensResponse, + derivationStyleProvider: DerivationStyleProvider, + ): List { + val customTokens = tokensResponse.tokens + .mapNotNull { token -> + maybeCreateCustomToken(token, coinsResponse.imageHost, derivationStyleProvider) + } + + val tokens = create(coinsResponse, tokensResponse, derivationStyleProvider) + + return customTokens + tokens + } + + private fun maybeCreateCustomToken( + token: UserTokensResponse.Token, + imageHost: String?, + derivationStyleProvider: DerivationStyleProvider, + ): ManagedCryptoCurrency? { + val blockchain = Blockchain.fromNetworkId(token.networkId) + ?.takeIf { it.isSupportedInApp() } + ?: return null + + if (!checkIsCustomToken(token, blockchain, derivationStyleProvider)) { + return null + } + + val network = getNetwork( + blockchain = blockchain, + extraDerivationPath = token.derivationPath, + derivationStyleProvider = derivationStyleProvider, + ) ?: return null + val contractAddress = token.contractAddress + + return if (contractAddress.isNullOrBlank()) { + ManagedCryptoCurrency.Custom.Coin( + currencyId = getCoinId(network, blockchain.toCoinId()), + name = token.name, + symbol = token.symbol, + iconUrl = getIconUrl(blockchain.id, imageHost), + network = network, + ) + } else { + ManagedCryptoCurrency.Custom.Token( + currencyId = getTokenId(network, token.id, contractAddress), + name = token.name, + symbol = token.symbol, + iconUrl = token.id?.let { getIconUrl(it, imageHost) }, + contractAddress = contractAddress, + network = network, + ) + } + } + + private fun createToken( + coinResponse: CoinsResponse.Coin, + tokensResponse: UserTokensResponse?, + imageHost: String?, + derivationStyleProvider: DerivationStyleProvider?, + ): ManagedCryptoCurrency? { + if (coinResponse.networks.isEmpty() || !coinResponse.active) return null + + return ManagedCryptoCurrency.Token( + id = ManagedCryptoCurrency.ID(coinResponse.id), + name = coinResponse.name, + symbol = coinResponse.symbol, + iconUrl = getIconUrl(coinResponse.id, imageHost), + availableNetworks = coinResponse.networks.mapNotNull { network -> + createSource(network, derivationStyleProvider) + }, + addedIn = findAddedInNetworksIds(coinResponse.id, tokensResponse), + ) + } + + private fun createSource( + networkResponse: CoinsResponse.Coin.Network, + derivationStyleProvider: DerivationStyleProvider?, + extraDerivationPath: String? = null, + ): SourceNetwork? { + val blockchain = Blockchain.fromNetworkId(networkResponse.networkId) + ?.takeIf { it.isSupportedInApp() } + ?: return null + + val network = getNetwork(blockchain, extraDerivationPath, derivationStyleProvider) ?: return null + val contractAddress = networkResponse.contractAddress + + return if (contractAddress.isNullOrBlank()) { + SourceNetwork.Main( + network = network, + ) + } else { + SourceNetwork.Default( + network = network, + contractAddress = contractAddress, + ) + } + } + + private fun findAddedInNetworksIds(currencyId: String, tokensResponse: UserTokensResponse?): Set { + if (tokensResponse == null) return emptySet() + + return tokensResponse.tokens + .filter { it.id == currencyId } + .map { it.networkId } + .mapNotNullTo(mutableSetOf()) { networkId -> + val blockchain = Blockchain.fromNetworkId(networkId) + + if (blockchain != null && blockchain.isSupportedInApp()) { + Network.ID(blockchain.id) + } else { + null + } + } + } + + private fun getIconUrl(id: String, imageHost: String?): String { + return "${imageHost ?: DEFAULT_IMAGE_HOST}large/$id.png" + } + + private fun checkIsCustomToken( + token: UserTokensResponse.Token, + blockchain: Blockchain, + derivationStyleProvider: DerivationStyleProvider, + ): Boolean = token.id.isNullOrBlank() || + checkIsCustomDerivationPath(token.derivationPath, blockchain, derivationStyleProvider) + + private fun checkIsCustomDerivationPath( + derivationPath: String?, + blockchain: Blockchain, + derivationStyleProvider: DerivationStyleProvider, + ): Boolean = derivationPath != blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath + + private companion object { + const val DEFAULT_IMAGE_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/" + } +} \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index be83e51fa2..de9edf3b4b 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -1,7 +1,10 @@ package com.tangem.data.markets -import com.tangem.data.markets.converters.* -import com.tangem.data.markets.utils.retryOnError +import com.tangem.data.common.utils.retryOnError +import com.tangem.data.markets.converters.TokenChartConverter +import com.tangem.data.markets.converters.TokenMarketInfoConverter +import com.tangem.data.markets.converters.TokenMarketListConverter +import com.tangem.data.markets.converters.toRequestParam import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.tangemTech.TangemTechApi @@ -10,6 +13,7 @@ import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.pagination.* import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong internal class DefaultMarketsTokenRepository( @@ -18,8 +22,6 @@ internal class DefaultMarketsTokenRepository( private val dispatcherProvider: CoroutineDispatcherProvider, ) : MarketsTokenRepository { - private val tokenListConverter = TokenMarketListConverter() - private fun createTokenMarketsFetcher(firstBatchSize: Int, nextBatchSize: Int) = LimitOffsetBatchFetcher( prefetchDistance = firstBatchSize, batchSize = nextBatchSize, @@ -41,9 +43,9 @@ internal class DefaultMarketsTokenRepository( interval = request.params.priceChangeInterval.toRequestParam(), order = request.params.order.toRequestParam(), search = searchText, - generalCoins = request.params.showUnder100kMarketCapTokens.not(), offset = request.offset, limit = request.limit, + timestamp = if (isFirstBatchFetching) null else requestTimeStamp.get(), ).getOrThrow() } @@ -57,13 +59,13 @@ internal class DefaultMarketsTokenRepository( } if (isFirstBatchFetching) { - requestTimeStamp.set(0) // TODO when backend is ready + requestTimeStamp.set(res.timestamp ?: 0) } val last = res.tokens.size < request.limit return BatchFetchResult.Success( - data = tokenListConverter.convert(res), + data = TokenMarketListConverter.convert(res), last = last, empty = res.tokens.isEmpty(), ) @@ -81,12 +83,53 @@ internal class DefaultMarketsTokenRepository( marketsApi = marketsApi, ) + val atomicInteger = AtomicInteger(0) + return BatchListSource( fetchDispatcher = dispatcherProvider.io, context = batchingContext, - generateNewKey = { it.size }, + generateNewKey = { atomicInteger.getAndIncrement() }, batchFetcher = createTokenMarketsFetcher(firstBatchSize = firstBatchSize, nextBatchSize = nextBatchSize), updateFetcher = tokenMarketsUpdateFetcher, ).toBatchFlow() } + + override suspend fun getChart( + fiatCurrencyCode: String, + interval: PriceChangeInterval, + tokenId: String, + ): TokenChart { + val response = marketsApi.getCoinChart( + currency = fiatCurrencyCode, + coinId = tokenId, + interval = interval.toRequestParam(), + ) + + return TokenChartConverter.convert(interval, response.getOrThrow()) + } + + override suspend fun getTokenInfo( + fiatCurrencyCode: String, + tokenId: String, + languageCode: String, + ): TokenMarketInfo { + val response = marketsApi.getCoinMarketData( + currency = fiatCurrencyCode, + coinId = tokenId, + language = languageCode, + ) + + return TokenMarketInfoConverter.convert(response.getOrThrow()) + } + + override suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String): TokenQuotes { + // TODO change method when backend is ready + val response = marketsApi.getCoinMarketData( + currency = fiatCurrencyCode, + coinId = tokenId, + language = "en", + ) + + return TokenMarketInfoConverter.convert(response.getOrThrow()).quotes + } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt index 625f90797d..3a377ab901 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt @@ -1,10 +1,9 @@ package com.tangem.data.markets -import com.tangem.data.markets.converters.TokenListChartConverter +import com.tangem.data.common.utils.retryOnError import com.tangem.data.markets.converters.TokenMarketChartsConverter -import com.tangem.data.markets.converters.TokenQuotesConverter +import com.tangem.data.markets.converters.TokenQuotesShortConverter import com.tangem.data.markets.converters.toRequestParam -import com.tangem.data.markets.utils.retryOnError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse @@ -14,16 +13,15 @@ import com.tangem.domain.markets.TokenMarketUpdateRequest import com.tangem.pagination.Batch import com.tangem.pagination.BatchUpdateFetcher import com.tangem.pagination.BatchUpdateResult -import kotlinx.coroutines.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch internal class MarketsBatchUpdateFetcher( private val marketsApi: TangemTechMarketsApi, private val tangemTechApi: TangemTechApi, ) : BatchUpdateFetcher, TokenMarketUpdateRequest> { - private val tokenListChartsConverter = TokenMarketChartsConverter(TokenListChartConverter()) - private val tokenQuotesConverter = TokenQuotesConverter() - override suspend fun BatchUpdateFetcher.UpdateContext>.fetchUpdateAsync( toUpdate: List>>, updateRequest: TokenMarketUpdateRequest, @@ -75,7 +73,7 @@ internal class MarketsBatchUpdateFetcher( val res = toUpdate.map { batch -> batch.copy( data = batch.data.map { - it.copy(tokenQuotes = tokenQuotesConverter.convert(it.id, quotesRes)) + it.copy(tokenQuotesShort = TokenQuotesShortConverter.convert(it.id, quotesRes)) }, ) } @@ -97,7 +95,7 @@ internal class MarketsBatchUpdateFetcher( key = batchToUpdate.key, data = batchToUpdate.data.map { it.copy( - tokenCharts = tokenListChartsConverter.convert( + tokenCharts = TokenMarketChartsConverter.convert( chartsToCopy = it.tokenCharts, tokenId = it.id, interval = updateRequest.interval, diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListChartConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenChartConverter.kt similarity index 83% rename from data/markets/src/main/java/com/tangem/data/markets/converters/TokenListChartConverter.kt rename to data/markets/src/main/java/com/tangem/data/markets/converters/TokenChartConverter.kt index 0b4cf678c6..744e6fef48 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListChartConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenChartConverter.kt @@ -4,13 +4,13 @@ import com.tangem.datasource.api.markets.models.response.TokenMarketChartRespons import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenChart -class TokenListChartConverter { +internal object TokenChartConverter { fun convert(interval: PriceChangeInterval, value: TokenMarketChartResponse): TokenChart { return TokenChart( interval = interval, priceY = value.prices.values.toList(), - timeStamp = value.prices.keys.toList(), + timeStamps = value.prices.keys.toList(), ) } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt index f47c0ff170..1875a5b609 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt @@ -3,13 +3,13 @@ package com.tangem.data.markets.converters import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarketListConfig -fun TokenMarketListConfig.Interval.toRequestParam(): String = when (this) { +internal fun TokenMarketListConfig.Interval.toRequestParam(): String = when (this) { TokenMarketListConfig.Interval.H24 -> "24h" TokenMarketListConfig.Interval.WEEK -> "1w" TokenMarketListConfig.Interval.MONTH -> "30d" } -fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { +internal fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { TokenMarketListConfig.Order.ByRating -> "rating" TokenMarketListConfig.Order.Trending -> "trending" TokenMarketListConfig.Order.Buyers -> "buyers" @@ -17,10 +17,10 @@ fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { TokenMarketListConfig.Order.TopLosers -> "losers" } -fun PriceChangeInterval.toRequestParam(): String = when (this) { +internal fun PriceChangeInterval.toRequestParam(): String = when (this) { PriceChangeInterval.H24 -> "24h" PriceChangeInterval.WEEK -> "1w" - PriceChangeInterval.MONTH -> "30d" + PriceChangeInterval.MONTH -> "1m" PriceChangeInterval.MONTH3 -> "3m" PriceChangeInterval.MONTH6 -> "6m" PriceChangeInterval.YEAR -> "1y" diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt index dcfdee4638..fdd44ffba9 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt @@ -3,31 +3,36 @@ package com.tangem.data.markets.converters import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarket +import com.tangem.domain.markets.TokenMarketListConfig -class TokenMarketChartsConverter( - private val tokenListChartConverter: TokenListChartConverter, -) { +internal object TokenMarketChartsConverter { fun convert( chartsToCopy: TokenMarket.Charts, tokenId: String, - interval: PriceChangeInterval, + interval: TokenMarketListConfig.Interval, value: TokenMarketChartListResponse, ): TokenMarket.Charts { val prices = requireNotNull(value[tokenId]) { "$tokenId is not found in the response. This shouldn't have happened." } return when (interval) { - PriceChangeInterval.H24 -> chartsToCopy.copy( - h24 = tokenListChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.H24 -> chartsToCopy.copy( + h24 = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) - PriceChangeInterval.WEEK -> chartsToCopy.copy( - week = tokenListChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.WEEK -> chartsToCopy.copy( + week = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) - PriceChangeInterval.MONTH -> chartsToCopy.copy( - month = tokenListChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.MONTH -> chartsToCopy.copy( + month = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) else -> error("unsupported interval=$interval. This shouldn't have happened.") } } + + private fun TokenMarketListConfig.Interval.toPriceChangeInterval(): PriceChangeInterval = when (this) { + TokenMarketListConfig.Interval.H24 -> PriceChangeInterval.H24 + TokenMarketListConfig.Interval.WEEK -> PriceChangeInterval.WEEK + TokenMarketListConfig.Interval.MONTH -> PriceChangeInterval.MONTH + } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt new file mode 100644 index 0000000000..e9314b0d7e --- /dev/null +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt @@ -0,0 +1,116 @@ +package com.tangem.data.markets.converters + +import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenQuotes +import com.tangem.utils.converter.Converter + +internal object TokenMarketInfoConverter : Converter { + + override fun convert(value: TokenMarketInfoResponse): TokenMarketInfo { + return with(value) { + TokenMarketInfo( + id = id, + name = name, + symbol = symbol, + quotes = getQuotes(), + networks = networks?.convert(), + shortDescription = shortDescription, + fullDescription = fullDescription, + insights = insights?.convert(), + metrics = metrics?.convert(), + links = links?.convert(), + pricePerformance = pricePerformance?.convert(), + ) + } + } + + private fun TokenMarketInfoResponse.getQuotes(): TokenQuotes { + return TokenQuotes( + currentPrice = currentPrice, + h24ChangePercent = priceChangePercentage?.day?.movePointLeft(2), + weekChangePercent = priceChangePercentage?.week?.movePointLeft(2), + monthChangePercent = priceChangePercentage?.month?.movePointLeft(2), + m3ChangePercent = priceChangePercentage?.threeMonths?.movePointLeft(2), + m6ChangePercent = priceChangePercentage?.sixMonths?.movePointLeft(2), + yearChangePercent = priceChangePercentage?.year?.movePointLeft(2), + allTimeChangePercent = priceChangePercentage?.allTime?.movePointLeft(2), + ) + } + + @JvmName("convertNetwork") + private fun List.convert(): List { + return map { + TokenMarketInfo.Network( + networkId = it.networkId, + exchangeable = it.exchangeable, + contractAddress = it.contractAddress, + decimalCount = it.decimalCount, + ) + } + } + + @JvmName("convertInsight") + private fun TokenMarketInfoResponse.Insights.convert(): TokenMarketInfo.Insights { + return TokenMarketInfo.Insights( + holdersChange = holdersChange?.convert(), + liquidityChange = liquidityChange?.convert(), + buyPressureChange = buyPressureChange?.convert(), + experiencedBuyerChange = experiencedBuyerChange?.convert(), + ) + } + + private fun TokenMarketInfoResponse.Change.convert(): TokenMarketInfo.Change { + return TokenMarketInfo.Change( + day = day, + week = week, + month = month, + ) + } + + private fun TokenMarketInfoResponse.Metrics.convert(): TokenMarketInfo.Metrics { + return TokenMarketInfo.Metrics( + marketRating = marketRating, + circulatingSupply = circulatingSupply, + marketCap = marketCap, + volume24h = volume24h, + totalSupply = totalSupply, + fullyDilutedValuation = fullyDilutedValuation, + ) + } + + private fun TokenMarketInfoResponse.Links.convert(): TokenMarketInfo.Links { + return TokenMarketInfo.Links( + officialLinks = officialLinks?.convert(), + social = social?.convert(), + repository = repository?.convert(), + blockchainSite = blockchainSite?.convert(), + ) + } + + @JvmName("convertLink") + private fun List.convert(): List { + return map { + TokenMarketInfo.Link( + title = it.title, + id = it.id, + link = it.link, + ) + } + } + + private fun TokenMarketInfoResponse.PricePerformance.convert(): TokenMarketInfo.PricePerformance { + return TokenMarketInfo.PricePerformance( + day = day?.convert(), + month = month?.convert(), + allTime = allTime?.convert(), + ) + } + + private fun TokenMarketInfoResponse.Range.convert(): TokenMarketInfo.Range { + return TokenMarketInfo.Range( + low = low, + high = high, + ) + } +} \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index 96f7e01ebf..72aae03ec4 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -1,12 +1,11 @@ package com.tangem.data.markets.converters import com.tangem.datasource.api.markets.models.response.TokenMarketListResponse -import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarket -import com.tangem.domain.markets.TokenQuotes +import com.tangem.domain.markets.TokenQuotesShort import com.tangem.utils.converter.Converter -class TokenMarketListConverter : Converter> { +internal object TokenMarketListConverter : Converter> { override fun convert(value: TokenMarketListResponse): List { val imageHost = value.imageHost ?: run { @@ -25,13 +24,11 @@ class TokenMarketListConverter : Converter - val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) - - preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs) - } + override fun saveLogMessage(message: String) { + appLogsStore.saveLogMessage(message) } - override suspend fun deleteDeprecatedLogs(maxSize: Int) { - appPreferencesStore.editData { preferences -> - val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) - - var sum = 0 - preferences.setObjectMap( - key = PreferencesKeys.APP_LOGS_KEY, - value = savedLogs.entries - .sortedBy(Map.Entry::key) - .takeLastWhile { - sum += it.value.length - sum < maxSize - } - .associate { it.key to it.value }, - ) - } + override fun deleteDeprecatedLogs(maxSize: Int) { + appLogsStore.deleteDeprecatedLogs(maxSize) } override suspend fun isSendTapHelpPreviewEnabled(): Boolean { 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 d1daa5b8a0..bddc8ef6e1 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 @@ -4,6 +4,7 @@ import com.tangem.data.settings.DefaultAppRatingRepository import com.tangem.data.settings.DefaultPermissionRepository import com.tangem.data.settings.DefaultPromoSettingsRepository import com.tangem.data.settings.DefaultSettingsRepository +import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.PermissionRepository @@ -21,8 +22,11 @@ internal object SettingsDataModule { @Provides @Singleton - fun provideSettingsRepository(appPreferencesStore: AppPreferencesStore): SettingsRepository { - return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore) + fun provideSettingsRepository( + appPreferencesStore: AppPreferencesStore, + appLogsStore: AppLogsStore, + ): SettingsRepository { + return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore, appLogsStore = appLogsStore) } @Provides diff --git a/data/staking/build.gradle.kts b/data/staking/build.gradle.kts index 0a1688dee1..017cc43973 100644 --- a/data/staking/build.gradle.kts +++ b/data/staking/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.staking) implementation(projects.domain.wallets.models) + implementation(projects.domain.legacy) /** Feature Api modules */ implementation(projects.features.staking.api) @@ -40,6 +41,8 @@ dependencies { implementation(projects.libs.blockchainSdk) + + implementation(deps.tangem.card.core) implementation(deps.tangem.blockchain) { exclude(module = "joda-time") } 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 bc015d17b5..9ce502abe3 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,14 @@ package com.tangem.data.staking +import android.util.Base64 import arrow.core.raise.catch import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionStatus +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toCompressedPublicKey import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.staking.converters.* import com.tangem.data.staking.converters.action.ActionStatusConverter @@ -23,7 +29,11 @@ import com.tangem.datasource.local.token.StakingBalanceStore import com.tangem.datasource.local.token.StakingYieldsStore import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.core.lce.lceFlow -import com.tangem.domain.staking.model.* +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata +import com.tangem.domain.staking.model.stakekit.NetworkType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList @@ -37,16 +47,17 @@ import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.toFormattedString +import com.tangem.utils.extensions.orZero import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@Suppress("LargeClass", "LongParameterList") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class DefaultStakingRepository( private val stakeKitApi: StakeKitApi, private val appPreferencesStore: AppPreferencesStore, @@ -55,6 +66,7 @@ internal class DefaultStakingRepository( private val cacheRegistry: CacheRegistry, private val dispatchers: CoroutineDispatcherProvider, private val stakingFeatureToggle: StakingFeatureToggles, + private val walletManagersFacade: WalletManagersFacade, ) : StakingRepository { private val stakingNetworkTypeConverter = StakingNetworkTypeConverter() @@ -92,8 +104,8 @@ internal class DefaultStakingRepository( value = emptyMap(), ) - override fun isStakingSupported(currencyId: String): Boolean { - return integrationIdMap.containsKey(currencyId) + override fun isStakingSupported(integrationKey: String): Boolean { + return integrationIdMap.containsKey(integrationKey) } override suspend fun fetchEnabledYields(refresh: Boolean) { @@ -129,7 +141,7 @@ internal class DefaultStakingRepository( val yield = getYield(cryptoCurrencyId, symbol) StakingEntryInfo( - interestRate = yield.apy, + interestRate = requireNotNull(yield.validators.maxByOrNull { it.apr.orZero() }?.apr), periodInDays = yield.metadata.cooldownPeriod.days, tokenSymbol = yield.token.symbol, ) @@ -146,7 +158,7 @@ internal class DefaultStakingRepository( val yields = getEnabledYields() ?: return@withContext StakingAvailability.Unavailable val prefetchedYield = findPrefetchedYield(yields, rawCurrencyId, symbol) - val isSupported = isStakingSupported(rawCurrencyId) + val isSupported = isStakingSupported(cryptoCurrencyId.getIntegrationKey()) when { prefetchedYield != null && isSupported -> { @@ -160,12 +172,30 @@ internal class DefaultStakingRepository( } } - override suspend fun createAction(params: ActionParams): StakingAction { + override suspend fun createAction( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingAction { return withContext(dispatchers.io) { val response = when (params.actionCommonType) { - StakingActionCommonType.ENTER -> stakeKitApi.createEnterAction(createActionRequestBody(params)) - StakingActionCommonType.EXIT -> stakeKitApi.createExitAction(createActionRequestBody(params)) - StakingActionCommonType.PENDING -> stakeKitApi.createPendingAction( + StakingActionCommonType.ENTER -> stakeKitApi.createEnterAction( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.EXIT -> stakeKitApi.createExitAction( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.PENDING_OTHER, + StakingActionCommonType.PENDING_REWARDS, + -> stakeKitApi.createPendingAction( createPendingActionRequestBody(params), ) } @@ -174,12 +204,30 @@ internal class DefaultStakingRepository( } } - override suspend fun estimateGas(params: ActionParams): StakingGasEstimate { + override suspend fun estimateGas( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingGasEstimate { return withContext(dispatchers.io) { val gasEstimateDTO = when (params.actionCommonType) { - StakingActionCommonType.ENTER -> stakeKitApi.estimateGasOnEnter(createActionRequestBody(params)) - StakingActionCommonType.EXIT -> stakeKitApi.estimateGasOnExit(createActionRequestBody(params)) - StakingActionCommonType.PENDING -> stakeKitApi.estimateGasOnPending( + StakingActionCommonType.ENTER -> stakeKitApi.estimateGasOnEnter( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.EXIT -> stakeKitApi.estimateGasOnExit( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.PENDING_REWARDS, + StakingActionCommonType.PENDING_OTHER, + -> stakeKitApi.estimateGasOnPending( createPendingActionRequestBody(params), ) } @@ -188,14 +236,26 @@ internal class DefaultStakingRepository( } } - override suspend fun constructTransaction(transactionId: String): StakingTransaction { + override suspend fun constructTransaction( + networkId: String, + fee: Fee, + transactionId: String, + ): Pair { return withContext(dispatchers.io) { val transactionResponse = stakeKitApi.constructTransaction( transactionId = transactionId, body = ConstructTransactionRequestBody(), ) - transactionConverter.convert(transactionResponse.getOrThrow()) + val transaction = transactionConverter.convert(transactionResponse.getOrThrow()) + val unsignedTransaction = transaction.unsignedTransaction ?: error("No unsigned transaction available") + val transactionData = TransactionData.Compiled( + value = getTransactionDataType(networkId, unsignedTransaction), + fee = fee, + status = TransactionStatus.Unconfirmed, + ) + + transaction to transactionData } } @@ -207,10 +267,7 @@ internal class DefaultStakingRepository( if (!stakingFeatureToggle.isStakingEnabled) return@withContext val cryptoCurrency = address.cryptoCurrency - val rawCurrencyId = - cryptoCurrency.id.rawCurrencyId ?: error("Staking custom tokens is not available") - - val integrationId = integrationIdMap[rawCurrencyId] ?: return@withContext + val integrationId = integrationIdMap[cryptoCurrency.id.getIntegrationKey()] ?: return@withContext cacheRegistry.invokeOnExpire( key = getYieldBalancesKey(userWalletId), @@ -241,7 +298,7 @@ internal class DefaultStakingRepository( send(YieldBalance.Empty) } else { launch(dispatchers.io) { - val integrationId = integrationIdMap[address.cryptoCurrency.id.rawCurrencyId] + val integrationId = integrationIdMap[address.cryptoCurrency.id.getIntegrationKey()] ?: error("Could not get integrationId") stakingBalanceStore.get(integrationId) .collectLatest { @@ -274,7 +331,7 @@ internal class DefaultStakingRepository( } else { fetchSingleYieldBalance(userWalletId, address) - val integrationId = integrationIdMap[address.cryptoCurrency.id.rawCurrencyId] + val integrationId = integrationIdMap[address.cryptoCurrency.id.getIntegrationKey()] ?: error("Could not get integrationId") val result = stakingBalanceStore.getSyncOrNull(integrationId) ?: return@withContext YieldBalance.Error yieldBalanceConverter.convert( @@ -304,8 +361,7 @@ internal class DefaultStakingRepository( addresses .mapNotNull { networkAddress -> val cryptoCurrency = networkAddress.cryptoCurrency - val rawCurrencyId = cryptoCurrency.id.rawCurrencyId ?: error("Currency raw id is null") - val integrationId = integrationIdMap[rawCurrencyId] + val integrationId = integrationIdMap[cryptoCurrency.id.getIntegrationKey()] if (integrationId != null) { networkAddress.address to integrationId @@ -435,12 +491,19 @@ internal class DefaultStakingRepository( } } - private fun createActionRequestBody(params: ActionParams): ActionRequestBody { + private suspend fun createActionRequestBody( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): ActionRequestBody { return ActionRequestBody( integrationId = params.integrationId, - addresses = Address(params.address), + addresses = Address( + address = params.address, + additionalAddresses = createAdditionalAddresses(userWalletId, network, params), + ), args = ActionRequestBodyArgs( - amount = params.amount.toFormattedString(params.token.decimals), + amount = params.amount.toPlainString(), inputToken = tokenConverter.convertBack(params.token), validatorAddress = params.validatorAddress, ), @@ -453,12 +516,29 @@ internal class DefaultStakingRepository( type = params.type ?: StakingActionType.UNKNOWN, passthrough = params.passthrough.orEmpty(), args = ActionRequestBodyArgs( - amount = params.amount.toFormattedString(params.token.decimals), + amount = params.amount.toPlainString(), validatorAddress = params.validatorAddress, ), ) } + private suspend fun createAdditionalAddresses( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): Address.AdditionalAddresses? { + val selectedWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + return when (params.token.network) { + NetworkType.COSMOS -> Address.AdditionalAddresses( + cosmosPubKey = Base64.encodeToString( + /* input = */ selectedWallet?.wallet?.publicKey?.blockchainKey?.toCompressedPublicKey(), + /* flags = */ Base64.NO_WRAP, + ), + ) + else -> null + } + } + override fun isStakeMoreAvailable(networkId: Network.ID): Boolean { val blockchain = Blockchain.fromId(networkId.value) return when (blockchain) { @@ -467,6 +547,27 @@ internal class DefaultStakingRepository( } } + override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval { + return when (cryptoCurrency.id.getIntegrationKey()) { + Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() -> { + StakingApproval.Needed(ETHEREUM_POLYGON_APPROVE_SPENDER) + } + else -> StakingApproval.Empty + } + } + + private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data { + val blockchain = Blockchain.fromId(networkId) + return when (blockchain) { + Blockchain.Solana, + Blockchain.Cosmos, + -> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes()) + Blockchain.Ethereum, + -> TransactionData.Compiled.Data.RawString(unsignedTransaction) + else -> error("Unsupported blockchain") + } + } + private fun findPrefetchedYield(yields: List, currencyId: String, symbol: String): Yield? { return yields.find { it.token.coinGeckoId == currencyId && it.token.symbol == symbol } } @@ -492,33 +593,38 @@ internal class DefaultStakingRepository( private fun getYieldBalancesKey(userWalletId: UserWalletId) = "yield_balance_${userWalletId.stringValue}" + private fun CryptoCurrency.ID.getIntegrationKey(): String = rawNetworkId.plus(rawCurrencyId) + private companion object { const val YIELDS_STORE_KEY = "yields" const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" + const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking" - const val ETHEREUM_INTEGRATION_ID = "ethereum-matic-native-staking" const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking" const val TRON_INTEGRATION_ID = "tron-trx-native-staking" const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking" - const val BINANCE_INTEGRATION_ID = "binance-bnb-native-staking" + const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" const val KAVA_INTEGRATION_ID = "kava-kava-native-staking" const val NEAR_INTEGRATION_ID = "near-near-native-staking" const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking" + const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + + // uncomment items as implementation is ready val integrationIdMap = mapOf( - Blockchain.Solana.toCoinId() to SOLANA_INTEGRATION_ID, - Blockchain.Cosmos.toCoinId() to COSMOS_INTEGRATION_ID, - Blockchain.Polkadot.toCoinId() to POLKADOT_INTEGRATION_ID, - Blockchain.Polygon.toCoinId() to ETHEREUM_INTEGRATION_ID, - Blockchain.Avalanche.toCoinId() to AVALANCHE_INTEGRATION_ID, - Blockchain.Tron.toCoinId() to TRON_INTEGRATION_ID, - Blockchain.Cronos.toCoinId() to CRONOS_INTEGRATION_ID, - Blockchain.Binance.toCoinId() to BINANCE_INTEGRATION_ID, - Blockchain.Kava.toCoinId() to KAVA_INTEGRATION_ID, - Blockchain.Near.toCoinId() to NEAR_INTEGRATION_ID, - Blockchain.Tezos.toCoinId() to TEZOS_INTEGRATION_ID, + Blockchain.Solana.run { id + toCoinId() } to SOLANA_INTEGRATION_ID, + Blockchain.Cosmos.run { id + toCoinId() } to COSMOS_INTEGRATION_ID, + Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + // Blockchain.Polkadot.run { id + toCoinId() } to POLKADOT_INTEGRATION_ID, + // Blockchain.Avalanche.run { id + toCoinId() } to AVALANCHE_INTEGRATION_ID, + // Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID, + // Blockchain.Cronos.run { id + toCoinId() } to CRONOS_INTEGRATION_ID, + // Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID, + // Blockchain.Kava.run { id + toCoinId() } to KAVA_INTEGRATION_ID, + // Blockchain.Near.run { id + toCoinId() } to NEAR_INTEGRATION_ID, + // Blockchain.Tezos.run { id + toCoinId() } to TEZOS_INTEGRATION_ID, ) } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt index c46a15f279..b1c9d7d47c 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt @@ -24,6 +24,7 @@ internal class YieldBalanceConverter : Converter Yield.RewardType.UNKNOWN } } + + private fun convertRewardSchedule(rewardTypeDTO: RewardScheduleDTO): RewardSchedule { + return when (rewardTypeDTO) { + RewardScheduleDTO.BLOCK -> RewardSchedule.BLOCK + RewardScheduleDTO.WEEK -> RewardSchedule.WEEK + RewardScheduleDTO.HOUR -> RewardSchedule.HOUR + RewardScheduleDTO.DAY -> RewardSchedule.DAY + RewardScheduleDTO.MONTH -> RewardSchedule.MONTH + RewardScheduleDTO.ERA -> RewardSchedule.ERA + RewardScheduleDTO.EPOCH -> RewardSchedule.EPOCH + else -> RewardSchedule.UNKNOWN + } + } + + private fun convertRewardClaiming( + rewardClaimingDTO: YieldDTO.MetadataDTO.RewardClaimingDTO, + ): Yield.Metadata.RewardClaiming { + return when (rewardClaimingDTO) { + YieldDTO.MetadataDTO.RewardClaimingDTO.AUTO -> Yield.Metadata.RewardClaiming.AUTO + YieldDTO.MetadataDTO.RewardClaimingDTO.MANUAL -> Yield.Metadata.RewardClaiming.MANUAL + else -> Yield.Metadata.RewardClaiming.UNKNOWN + } + } + + private fun convertArgType(value: String): Yield.Args.ArgType { + return when (value) { + "address" -> Yield.Args.ArgType.ADDRESS + "amount" -> Yield.Args.ArgType.AMOUNT + else -> Yield.Args.ArgType.UNKNOWN + } + } } \ 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 f0562851c1..d261cdece4 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 @@ -13,6 +13,7 @@ import com.tangem.datasource.local.token.StakingBalanceStore import com.tangem.datasource.local.token.StakingYieldsStore import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -35,6 +36,7 @@ internal object StakingDataModule { dispatchers: CoroutineDispatcherProvider, stakingFeatureToggle: StakingFeatureToggles, cacheRegistry: CacheRegistry, + walletManagersFacade: WalletManagersFacade, ): StakingRepository { return DefaultStakingRepository( stakeKitApi = stakeKitApi, @@ -44,6 +46,7 @@ internal object StakingDataModule { dispatchers = dispatchers, cacheRegistry = cacheRegistry, stakingFeatureToggle = stakingFeatureToggle, + walletManagersFacade = walletManagersFacade, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt index a1a8018cab..611ccafe2b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt @@ -2,7 +2,7 @@ package com.tangem.data.tokens.paging import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.data.tokens.utils.getNetworkStandardType +import com.tangem.data.common.currency.getNetworkStandardType import com.tangem.datasource.api.tangemTech.models.CoinsResponse import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Token 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 b24c5226bc..ec3720fe1b 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 @@ -6,7 +6,10 @@ import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.cache.CacheRegistry -import com.tangem.data.tokens.utils.* +import com.tangem.data.common.currency.* +import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory +import com.tangem.data.tokens.utils.CustomTokensMerger +import com.tangem.data.tokens.utils.UserTokensBackwardCompatibility import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi @@ -454,6 +457,21 @@ internal class DefaultCurrenciesRepository( ) ?: error("Unable to create token") } + override suspend fun hasTokens(userWalletId: UserWalletId, network: Network): Boolean { + val userWallet = getUserWallet(userWalletId) + fetchTokensIfCacheExpired(userWallet, refresh = false) + + val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) { + "Unable to find tokens response for user wallet with provided ID: $userWalletId" + } + + return storedTokens.tokens.any { + it.contractAddress != null && + it.networkId == network.backendId && + it.derivationPath == network.derivationPath.value + } + } + private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow> { return userTokensStore.get(userWallet.walletId).map { storedTokens -> responseCurrenciesFactory.createCurrencies( diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksCompatibilityRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksCompatibilityRepository.kt index 02fdef2739..c0ea21218c 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksCompatibilityRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksCompatibilityRepository.kt @@ -3,7 +3,7 @@ package com.tangem.data.tokens.repository import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.card.EllipticCurve -import com.tangem.data.tokens.utils.getNetwork +import com.tangem.data.common.currency.getNetwork import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.configs.CardConfig import com.tangem.domain.common.extensions.* diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt index bcc65da983..de6ad111ab 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt @@ -5,9 +5,9 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.data.common.cache.CacheRegistry +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory import com.tangem.data.tokens.utils.NetworkStatusFactory -import com.tangem.data.tokens.utils.ResponseCryptoCurrenciesFactory import com.tangem.datasource.local.network.NetworksStatusesStore import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -100,7 +100,7 @@ internal class DefaultNetworksRepository( override fun isNeedToCreateAccountWithoutReserve(network: Network): Boolean { val blockchain = Blockchain.fromNetworkId(network.id.value) - return blockchain == Blockchain.Aptos + return REQUIRED_ACCOUNT_WITHOUT_RESERVE_BLOCKCHAINS.contains(blockchain) } override suspend fun getNetworkAddresses( @@ -345,4 +345,9 @@ internal class DefaultNetworksRepository( private fun getNetworksStatusesCacheKey(userWalletId: UserWalletId, network: Network): String { return "network_status_${userWalletId}_${network.id.value}_${network.derivationPath.value}" } + + private companion object { + + val REQUIRED_ACCOUNT_WITHOUT_RESERVE_BLOCKCHAINS = listOf(Blockchain.Aptos, Blockchain.Filecoin) + } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultPolkadotAccountHealthCheckRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultPolkadotAccountHealthCheckRepository.kt index 049991280b..82f9f9465a 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultPolkadotAccountHealthCheckRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultPolkadotAccountHealthCheckRepository.kt @@ -9,7 +9,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys.POLKADOT_HEALTH_CHECKED_IMMUTABLE_ACCOUNTS_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.POLKADOT_HEALTH_CHECKED_RESET_ACCOUNTS_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY -import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.datasource.local.preferences.utils.getObjectMapSync import com.tangem.datasource.local.preferences.utils.getObjectSetSync import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.repository.PolkadotAccountHealthCheckRepository @@ -97,7 +97,7 @@ internal class DefaultPolkadotAccountHealthCheckRepository( runCatching { do { // Getting batch of extrinsics to check - val lastChecked = appPreferencesStore.getObjectMap(POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY) + val lastChecked = appPreferencesStore.getObjectMapSync(POLKADOT_HEALTH_CHECK_LAST_INDEXED_TX_KEY) val lastExtrinsic = lastChecked[address] val extrinsicListResult = accountCheckerProvider.getExtrinsicList(afterExtrinsicId = lastExtrinsic) extrinsicListResult.extrinsic diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CardCryptoCurrenciesFactory.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CardCryptoCurrenciesFactory.kt index a2993452d9..4d6056cbb0 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CardCryptoCurrenciesFactory.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/CardCryptoCurrenciesFactory.kt @@ -1,6 +1,7 @@ package com.tangem.data.tokens.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.domain.common.TapWorkarounds.isTestCard import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.common.util.derivationStyleProvider diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt index 81905be776..0aa2528c5c 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt @@ -122,6 +122,10 @@ internal class NetworkStatusFactory { Address.Type.Secondary -> NetworkAddress.Address.Type.Secondary } + if (address.value.isBlank()) { + Timber.w("Address value is blank") + } + return NetworkAddress.Address(address.value, type) } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt index 819dbe2a39..c3600a3053 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt @@ -1,5 +1,7 @@ package com.tangem.data.tokens.utils +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.datasource.api.tangemTech.models.QuotesResponse /** @@ -48,14 +50,16 @@ internal class QuotesUnsupportedCurrenciesIdAdapter { /** * Map that contains unsupported currencies and their replacement for request */ + private val ethCoinId = Blockchain.Ethereum.toCoinId() private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = mapOf( - "optimistic-ethereum" to "ethereum", - "arbitrum-one" to "ethereum", - "zksync-ethereum" to "ethereum", - "manta-pacific" to "ethereum", - "polygon-zkevm-ethereum" to "ethereum", - "aurora-ethereum" to "ethereum", - "base-ethereum" to "ethereum", + Blockchain.Optimism.toCoinId() to ethCoinId, + Blockchain.Arbitrum.toCoinId() to ethCoinId, + Blockchain.ZkSyncEra.toCoinId() to ethCoinId, + Blockchain.Manta.toCoinId() to ethCoinId, + Blockchain.PolygonZkEVM.toCoinId() to ethCoinId, + Blockchain.Aurora.toCoinId() to ethCoinId, + Blockchain.Base.toCoinId() to ethCoinId, + Blockchain.Blast.toCoinId() to ethCoinId, ) } } \ No newline at end of file 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 56922c5bf0..87f16d07be 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 @@ -16,6 +16,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.extensions.hexToBytes import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.models.TransactionType @@ -24,6 +25,7 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import timber.log.Timber +import java.math.BigDecimal import java.math.BigInteger import com.tangem.blockchain.blockchains.tron.TransactionType as SdkTransactionType @@ -42,15 +44,15 @@ internal class DefaultTransactionRepository( network: Network, txExtras: TransactionExtras?, hash: String?, - ): TransactionData.Uncompiled? = withContext(coroutineDispatcherProvider.io) { + ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) val walletManager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, blockchain = blockchain, derivationPath = network.derivationPath.value, - ) + ) ?: error("Wallet manager not found") - return@withContext walletManager?.createTransactionDataInternal( + return@withContext walletManager.createTransactionDataInternal( amount = amount, fee = fee, memo = memo, @@ -61,6 +63,48 @@ internal class DefaultTransactionRepository( ) } + override suspend fun createApprovalTransaction( + amount: Amount, + fee: Fee, + contractAddress: String, + spenderAddress: String, + userWalletId: UserWalletId, + network: Network, + hash: String?, + ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) ?: error("Wallet manager not found") + val approver = walletManager as? Approver ?: error("Cannot cast to Approver") + + val approvalData = approver.getApproveData( + spenderAddress = spenderAddress, + value = amount, + ) + + val extras = createTransactionDataExtras( + data = approvalData, + network = network, + transactionType = TransactionType.APPROVE, + nonce = null, + gasLimit = null, + ) + + return@withContext createTransaction( + amount = amount, + fee = fee, + memo = null, + destination = contractAddress, + userWalletId = userWalletId, + network = network, + txExtras = extras, + hash = hash, + ) + } + override suspend fun validateTransaction( amount: Amount, fee: Fee?, @@ -141,6 +185,28 @@ internal class DefaultTransactionRepository( } } + override suspend fun getAllowance( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency.Token, + spenderAddress: String, + ): BigDecimal { + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, cryptoCurrency.network) + val blockchain = Blockchain.fromId(cryptoCurrency.network.id.value) + val allowanceResult = (walletManager as? Approver)?.getAllowance( + spenderAddress, + Token( + symbol = blockchain.currency, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) ?: error("Cannot cast to Approver") + + return allowanceResult.fold( + onSuccess = { it }, + onFailure = { error(it) }, + ) + } + private fun convertToSdkTransactionType(transactionType: TransactionType): SdkTransactionType { return when (transactionType) { TransactionType.APPROVE -> SdkTransactionType.APPROVE diff --git a/data/visa/src/debug/kotlin/com/tangem/data/visa/utils/CurrencyUtils.kt b/data/visa/src/debug/kotlin/com/tangem/data/visa/utils/CurrencyUtils.kt index bcc9aeb311..405d55d6a8 100644 --- a/data/visa/src/debug/kotlin/com/tangem/data/visa/utils/CurrencyUtils.kt +++ b/data/visa/src/debug/kotlin/com/tangem/data/visa/utils/CurrencyUtils.kt @@ -1,15 +1,7 @@ package com.tangem.data.visa.utils -import android.os.Build -import timber.log.Timber import java.util.Currency -internal fun findCurrencyByNumericCode(code: Int): Currency { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Currency.getAvailableCurrencies().firstOrNull { it.numericCode == code } - ?: Currency.getInstance(VisaConstants.fiatCurrency.code) - } else { - Timber.w("Unable to get currency by numeric code on API level ${Build.VERSION.SDK_INT}") - Currency.getInstance(VisaConstants.fiatCurrency.code) - } -} \ No newline at end of file +internal fun findCurrencyByNumericCode(code: Int) = + Currency.getAvailableCurrencies().firstOrNull { it.numericCode == code } + ?: Currency.getInstance(VisaConstants.fiatCurrency.code) \ No newline at end of file diff --git a/domain/app-currency/models/build.gradle.kts b/domain/app-currency/models/build.gradle.kts index 7ff7fb7522..6b18f3f83f 100644 --- a/domain/app-currency/models/build.gradle.kts +++ b/domain/app-currency/models/build.gradle.kts @@ -1,4 +1,9 @@ plugins { alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) id("configuration") +} + +dependencies { + implementation(deps.kotlin.serialization) } \ No newline at end of file diff --git a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt index 9d5261347a..adadcdcbac 100644 --- a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt +++ b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt @@ -1,5 +1,8 @@ package com.tangem.domain.appcurrency.model +import kotlinx.serialization.Serializable + +@Serializable data class AppCurrency( val code: String, val name: String, diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt index 4f107a0948..5990fe9c1a 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt @@ -1,30 +1,140 @@ package com.tangem.domain.card import arrow.core.Either +import com.tangem.blockchain.common.Blockchain +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.card.repository.DerivationsRepository import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.operations.derivation.ExtendedPublicKeysMap /** * Derivates an exteneded public key (xpub) based on blockchain hardened derivation */ class GetExtendedPublicKeyForCurrencyUseCase( private val derivationsRepository: DerivationsRepository, + private val walletManagersFacade: WalletManagersFacade, ) { - suspend operator fun invoke( - userWalletId: UserWalletId, - derivation: Network.DerivationPath, - ): Either { + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either { return Either.catch { - val derivationPath = requireNotNull(derivation.value?.let { DerivationPath(it) }) { - error("Derivation is null") + val userWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + ?: error("Wallet not found") + + val blockchain = Blockchain.fromId(network.id.value) + val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain) + + val hdKey = if (isSecp256k1Blockchain) { + userWallet.wallet.publicKey.derivationType?.hdKey ?: error("No derivation found") + } else { + error("No derivation found") } - val hardenedNodes = derivationPath.nodes.filter { it.isHardened } - val hardenedDerivation = DerivationPath(hardenedNodes) - derivationsRepository.deriveExtendedPublicKey(userWalletId, hardenedDerivation) - ?.serialize(NetworkType.Mainnet).orEmpty() + + var childKey = makeChildKey( + isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(), + extendedPublicKey = hdKey.extendedPublicKey, + derivationPath = hdKey.path, + ) + + var parentKey = Key( + derivationPath = childKey.derivationPath.dropLastNodes(1), + extendedPublicKey = null, + ) + + val pendingDerivations = getPendingDerivations(childKey, parentKey) + val derivedKeys = deriveKeys( + userWalletId = userWalletId, + seedKey = userWallet.wallet.publicKey.seedKey, + paths = pendingDerivations, + ) + + if (childKey.extendedPublicKey == null) { + childKey = childKey.copy( + extendedPublicKey = derivedKeys[childKey.derivationPath] ?: error("Failed to derive child key"), + ) + } + + if (parentKey.extendedPublicKey == null) { + parentKey = parentKey.copy( + extendedPublicKey = derivedKeys[parentKey.derivationPath] ?: error("Failed to derive parent key"), + ) + } + + makeExtendedKey(childKey, parentKey, network.isTestnet) } } + + private suspend fun deriveKeys( + userWalletId: UserWalletId, + seedKey: ByteArray, + paths: MutableList, + ): ExtendedPublicKeysMap { + val result = derivationsRepository.derivePublicKeys(userWalletId, mapOf(ByteArrayKey(seedKey) to paths)) + return result.getValue(ByteArrayKey(seedKey)) + } + + private fun makeExtendedKey(childKey: Key, parentKey: Key, isTestnet: Boolean): String { + val publicKey = childKey.extendedPublicKey?.publicKey ?: error("No public key found") + val chainCode = childKey.extendedPublicKey.chainCode + val lastChildNode = childKey.derivationPath.nodes.last() + val parentPublicKey = parentKey.extendedPublicKey?.publicKey + + val depth = childKey.derivationPath.nodes.size + val childNumber = lastChildNode.index + val parentFingerprint = parentPublicKey + ?.calculateSha256()?.calculateRipemd160() + ?.take(PARENT_FINGERPRINT_SIZE)?.toByteArray() + ?: error("No parent fingerprint found") + + val net = if (isTestnet) NetworkType.Testnet else NetworkType.Mainnet + return ExtendedPublicKey( + publicKey = publicKey, + chainCode = chainCode, + depth = depth, + parentFingerprint = parentFingerprint, + childNumber = childNumber, + ).serialize(net) + } + + private fun getPendingDerivations(childKey: Key, parentKey: Key): MutableList { + val pendingDerivations = mutableListOf() + + if (childKey.extendedPublicKey == null) { + pendingDerivations.add(childKey.derivationPath) + } + + if (parentKey.extendedPublicKey == null) { + pendingDerivations.add(parentKey.derivationPath) + } + + return pendingDerivations + } + + private fun makeChildKey( + isBip44DerivationStyleXPUB: Boolean, + extendedPublicKey: ExtendedPublicKey, + derivationPath: DerivationPath, + ): Key = if (isBip44DerivationStyleXPUB) { + Key(derivationPath.dropLastNodes(2), null) + } else { + Key(derivationPath, extendedPublicKey) + } + + private fun DerivationPath.dropLastNodes(count: Int): DerivationPath { + return DerivationPath(nodes.dropLast(count)) + } + + private data class Key( + val derivationPath: DerivationPath, + val extendedPublicKey: ExtendedPublicKey?, + ) + + private companion object { + const val PARENT_FINGERPRINT_SIZE = 4 + } } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt index 209b68817a..d6b474839a 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt @@ -1,9 +1,10 @@ package com.tangem.domain.card.repository +import com.tangem.common.extensions.ByteArrayKey import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.operations.derivation.ExtendedPublicKeysMap interface DerivationsRepository { @@ -11,5 +12,8 @@ interface DerivationsRepository { suspend fun derivePublicKeys(userWalletId: UserWalletId, currencies: List) @Throws - suspend fun deriveExtendedPublicKey(userWalletId: UserWalletId, derivation: DerivationPath): ExtendedPublicKey? + suspend fun derivePublicKeys( + userWalletId: UserWalletId, + derivations: Map>, + ): Map } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index a671bad033..a3cbd7d4f8 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -16,7 +16,8 @@ internal class FeedbackDataBuilder { fun addCardInfo(cardInfo: CardInfo) { builder.appendKeyValue("Card ID", cardInfo.cardId) builder.appendKeyValue("Firmware version", cardInfo.firmwareVersion) - builder.appendKeyValue("Imported wallet", if (cardInfo.isImported) "yes" else "no") + builder.appendKeyValue("Linked cards count", cardInfo.cardsCount) + builder.appendKeyValue("Has seed phrase", cardInfo.isImported.toString()) builder.appendKeyValue("Card Blockchain", cardInfo.cardBlockchain) builder.appendSignedHashes(cardInfo.signedHashesList) } @@ -34,12 +35,14 @@ internal class FeedbackDataBuilder { builder.appendKeyValue("Outputs count", outputsCount) if (tokens.isNotEmpty()) { - builder.append("Tokens:") builder.breakLine() tokens.forEach { token -> - builder.appendKeyValue("ID", token.id ?: "[custom token]") + builder.appendKeyValue("Token ID", token.id ?: "[custom token]") builder.appendKeyValue("Name", token.name) builder.appendKeyValue("Contract address", token.contractAddress) + builder.appendKeyValue("Decimals", token.decimals) + + builder.breakLine() } } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt index da0a0df2a5..a3665f64d2 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt @@ -24,5 +24,6 @@ data class BlockchainInfo( val id: String?, val name: String, val contractAddress: String, + val decimals: String, ) } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt index 2e7e710cc1..681cb957c0 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt @@ -6,6 +6,7 @@ data class CardInfo( val userWalletId: UserWalletId?, val cardId: String, val firmwareVersion: String, + val cardsCount: String, val cardBlockchain: String?, val signedHashesList: List, val isImported: Boolean, diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/utils/BigDecimalUtils.kt b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt similarity index 72% rename from domain/tokens/models/src/main/java/com/tangem/domain/tokens/utils/BigDecimalUtils.kt rename to domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt index fe93d1d8ec..ad26bc198e 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/utils/BigDecimalUtils.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/utils/BigDecimalUtils.kt @@ -1,13 +1,13 @@ -package com.tangem.domain.tokens.utils +package com.tangem.domain.utils -import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Token import com.tangem.domain.tokens.model.CryptoCurrency import java.math.BigDecimal +import com.tangem.blockchain.common.Amount as SdkAmount -/** Converts `BigDecimal` [cryptoCurrency] to [Amount] */ -fun BigDecimal.convertToAmount(cryptoCurrency: CryptoCurrency) = Amount( +/** Converts `BigDecimal` [cryptoCurrency] to [SdkAmount] */ +fun BigDecimal.convertToSdkAmount(cryptoCurrency: CryptoCurrency): SdkAmount = SdkAmount( currencySymbol = cryptoCurrency.symbol, value = this, decimals = cryptoCurrency.decimals, diff --git a/domain/manage-tokens/.gitignore b/domain/manage-tokens/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/manage-tokens/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/manage-tokens/build.gradle.kts b/domain/manage-tokens/build.gradle.kts new file mode 100644 index 0000000000..b24f993032 --- /dev/null +++ b/domain/manage-tokens/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + + /* Domain */ + api(projects.domain.manageTokens.models) + api(projects.domain.core) + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + + /* Core */ + api(projects.core.pagination) +} \ No newline at end of file diff --git a/domain/manage-tokens/models/.gitignore b/domain/manage-tokens/models/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/manage-tokens/models/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/manage-tokens/models/build.gradle.kts b/domain/manage-tokens/models/build.gradle.kts new file mode 100644 index 0000000000..90f5e66f52 --- /dev/null +++ b/domain/manage-tokens/models/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + + /* Domain */ + implementation(projects.domain.tokens.models) +} \ No newline at end of file diff --git a/domain/manage-tokens/models/src/main/kotlin/com/tangem/domain/managetokens/model/ManagedCryptoCurrency.kt b/domain/manage-tokens/models/src/main/kotlin/com/tangem/domain/managetokens/model/ManagedCryptoCurrency.kt new file mode 100644 index 0000000000..f7f82002cf --- /dev/null +++ b/domain/manage-tokens/models/src/main/kotlin/com/tangem/domain/managetokens/model/ManagedCryptoCurrency.kt @@ -0,0 +1,87 @@ +package com.tangem.domain.managetokens.model + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network + +sealed class ManagedCryptoCurrency { + + abstract val id: ID + abstract val name: String + abstract val symbol: String + abstract val iconUrl: String? + + sealed class Custom : ManagedCryptoCurrency() { + + abstract val currencyId: CryptoCurrency.ID + abstract val network: Network + + override val id: ID + get() = ID(currencyId.value) + + data class Token( + override val currencyId: CryptoCurrency.ID, + override val name: String, + override val symbol: String, + override val iconUrl: String?, + override val network: Network, + val contractAddress: String, + ) : Custom() + + data class Coin( + override val currencyId: CryptoCurrency.ID, + override val name: String, + override val symbol: String, + override val iconUrl: String?, + override val network: Network, + ) : Custom() + } + + data class Token( + override val id: ID, + override val name: String, + override val symbol: String, + override val iconUrl: String, + val availableNetworks: List, + val addedIn: Set, + ) : ManagedCryptoCurrency() { + + val isAdded: Boolean = addedIn.isNotEmpty() + } + + @JvmInline + value class ID(val value: String) + + sealed class SourceNetwork { + + abstract val network: Network + + val id: Network.ID + get() = network.id + + val typeName: String + get() = when (this) { + is Main -> MAIN_NETWORK_TYPE_NAME + is Default -> when (network.standardType) { + is Network.StandardType.BEP2, + is Network.StandardType.BEP20, + is Network.StandardType.ERC20, + is Network.StandardType.TRC20, + -> network.standardType.name + is Network.StandardType.Unspecified -> "" + } + } + + data class Main( + override val network: Network, + ) : SourceNetwork() + + data class Default( + override val network: Network, + val contractAddress: String, + ) : SourceNetwork() + + private companion object { + const val MAIN_NETWORK_TYPE_NAME = "MAIN" + } + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetManagedTokensUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetManagedTokensUseCase.kt new file mode 100644 index 0000000000..b46140c043 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetManagedTokensUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.managetokens + +import com.tangem.domain.managetokens.model.ManageTokensListBatchFlow +import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext +import com.tangem.domain.managetokens.repository.ManageTokensRepository + +class GetManagedTokensUseCase( + private val repository: ManageTokensRepository, +) { + + operator fun invoke(context: ManageTokensListBatchingContext, batchSize: Int = 40): ManageTokensListBatchFlow { + return repository.getTokenListBatchFlow(context, batchSize) + } +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensListConfig.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensListConfig.kt new file mode 100644 index 0000000000..6b4dc9dca9 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensListConfig.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.managetokens.model + +import com.tangem.domain.wallets.models.UserWalletId + +data class ManageTokensListConfig( + val userWalletId: UserWalletId?, + val searchText: String?, +) \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensTypealiases.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensTypealiases.kt new file mode 100644 index 0000000000..0058f82863 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensTypealiases.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.managetokens.model + +import com.tangem.pagination.BatchFlow +import com.tangem.pagination.BatchingContext + +typealias ManageTokensListBatchingContext = BatchingContext + +typealias ManageTokensListBatchFlow = BatchFlow, ManageTokensUpdateAction> \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensUpdateAction.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensUpdateAction.kt new file mode 100644 index 0000000000..75b1b2e4ad --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/model/ManageTokensUpdateAction.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.managetokens.model + +import com.tangem.domain.tokens.model.Network + +sealed class ManageTokensUpdateAction { + + data class AddCurrency( + val currencyId: ManagedCryptoCurrency.ID, + val networkId: Network.ID, + val isSelected: Boolean, + ) : ManageTokensUpdateAction() +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt new file mode 100644 index 0000000000..3104d98d64 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/ManageTokensRepository.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.managetokens.repository + +import com.tangem.domain.managetokens.model.ManageTokensListBatchFlow +import com.tangem.domain.managetokens.model.ManageTokensListBatchingContext + +interface ManageTokensRepository { + + fun getTokenListBatchFlow(context: ManageTokensListBatchingContext, batchSize: Int): ManageTokensListBatchFlow +} \ No newline at end of file diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 5529922be2..885998c263 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -11,10 +11,12 @@ android { dependencies { - api(projects.domain.markets.models) + api(projects.domain.appCurrency.models) api(projects.domain.core) api(projects.core.pagination) + api(projects.domain.markets.models) implementation(deps.kotlin.serialization) implementation(projects.domain.tokens.models) + implementation(projects.core.utils) } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt index d239ae395a..4e23077cf1 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt @@ -5,9 +5,9 @@ import java.math.BigDecimal data class TokenChart( val interval: PriceChangeInterval, val priceY: List, - val timeStamp: List, + val timeStamps: List, ) { init { - require(priceY.size == timeStamp.size) + require(priceY.size == timeStamps.size) } } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt index b7316cf79a..ac425d8e8a 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt @@ -8,7 +8,7 @@ data class TokenMarket( val symbol: String, val marketRating: Int?, val marketCap: BigDecimal?, - val tokenQuotes: TokenQuotes, + val tokenQuotesShort: TokenQuotesShort, val tokenCharts: Charts, private val imageHost: String, ) { diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt new file mode 100644 index 0000000000..7fe496e5f1 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt @@ -0,0 +1,70 @@ +package com.tangem.domain.markets + +import java.math.BigDecimal + +data class TokenMarketInfo( + val id: String, + val name: String, + val symbol: String, + val quotes: TokenQuotes, + val networks: List?, + val shortDescription: String?, + val fullDescription: String?, + val insights: Insights?, + val metrics: Metrics?, + val links: Links?, + val pricePerformance: PricePerformance?, +) { + data class Network( + val networkId: String, + val exchangeable: Boolean, + val contractAddress: String?, + val decimalCount: Int?, + ) + + data class Insights( + val holdersChange: Change?, + val liquidityChange: Change?, + val buyPressureChange: Change?, + val experiencedBuyerChange: Change?, + ) + + data class Change( + val day: BigDecimal?, + val week: BigDecimal?, + val month: BigDecimal?, + ) + + data class Metrics( + val marketRating: Int?, + val circulatingSupply: BigDecimal?, + val marketCap: BigDecimal?, + val volume24h: BigDecimal?, + val totalSupply: BigDecimal?, + val fullyDilutedValuation: BigDecimal?, + ) + + data class Links( + val officialLinks: List?, + val social: List?, + val repository: List?, + val blockchainSite: List?, + ) + + data class Link( + val title: String, + val id: String?, + val link: String, + ) + + data class PricePerformance( + val day: Range?, + val month: Range?, + val allTime: Range?, + ) + + data class Range( + val low: BigDecimal?, + val high: BigDecimal?, + ) +} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt index ad952091b6..c3633f3925 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt @@ -3,7 +3,6 @@ package com.tangem.domain.markets data class TokenMarketListConfig( val fiatPriceCurrency: String, val searchText: String?, - val showUnder100kMarketCapTokens: Boolean, val priceChangeInterval: Interval, val order: Order, ) { diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt index bcac52fddb..a23a9136ba 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt @@ -7,7 +7,7 @@ sealed class TokenMarketUpdateRequest { ) : TokenMarketUpdateRequest() data class UpdateChart( - val interval: PriceChangeInterval, + val interval: TokenMarketListConfig.Interval, val currency: String, ) : TokenMarketUpdateRequest() } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt index 7a162c9aac..18826801e3 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt @@ -4,15 +4,11 @@ import java.math.BigDecimal data class TokenQuotes( val currentPrice: BigDecimal, - private val priceChanges: Map, -) { - init { - require(priceChanges.containsKey(PriceChangeInterval.H24)) - require(priceChanges.containsKey(PriceChangeInterval.WEEK)) - require(priceChanges.containsKey(PriceChangeInterval.MONTH)) - } - - fun h24Percent() = priceChanges[PriceChangeInterval.H24]!! - fun weekPercent() = priceChanges[PriceChangeInterval.WEEK]!! - fun monthPercent() = priceChanges[PriceChangeInterval.MONTH]!! -} \ No newline at end of file + val h24ChangePercent: BigDecimal?, + val weekChangePercent: BigDecimal?, + val monthChangePercent: BigDecimal?, + val m3ChangePercent: BigDecimal?, + val m6ChangePercent: BigDecimal?, + val yearChangePercent: BigDecimal?, + val allTimeChangePercent: BigDecimal?, +) \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt new file mode 100644 index 0000000000..3b6bb3bdf1 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.markets + +import java.math.BigDecimal + +data class TokenQuotesShort( + val currentPrice: BigDecimal, + val h24ChangePercent: BigDecimal, + val weekChangePercent: BigDecimal, + val monthChangePercent: BigDecimal, +) \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt index 8ba13797a3..c182f7bd43 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt @@ -16,6 +16,7 @@ class GetMarketsTokenListFlowUseCase( firstBatchSize = batchFlowType.firstBatchSize, nextBatchSize = batchFlowType.nextBatchSize, ) + // TODO listen quotes updates flow and update them in other parts of the application } enum class BatchFlowType( diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt new file mode 100644 index 0000000000..c7935cb4e0 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.utils.SupportedLanguages + +class GetTokenMarketInfoUseCase( + private val marketsTokenRepository: MarketsTokenRepository, +) { + + suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { + return Either.catch { + marketsTokenRepository.getTokenInfo( + fiatCurrencyCode = appCurrency.code, + tokenId = tokenId, + languageCode = SupportedLanguages.getCurrentSupportedLanguageCode(), + ) + }.mapLeft {} + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt new file mode 100644 index 0000000000..c686ff3874 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.repositories.MarketsTokenRepository + +class GetTokenPriceChartUseCase( + private val marketsTokenRepository: MarketsTokenRepository, +) { + + suspend operator fun invoke( + appCurrency: AppCurrency, + interval: PriceChangeInterval, + tokenId: String, + ): Either { + return Either.catch { + marketsTokenRepository.getChart( + fiatCurrencyCode = appCurrency.code, + interval = interval, + tokenId = tokenId, + ) + }.mapLeft {} + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt new file mode 100644 index 0000000000..6477010d57 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.repositories.MarketsTokenRepository + +class GetTokenQuotesUseCase( + private val marketsTokenRepository: MarketsTokenRepository, +) { + + suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { + return Either.catch { + marketsTokenRepository.getTokenQuotes( + fiatCurrencyCode = appCurrency.code, + tokenId = tokenId, + ) + }.mapLeft {} + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index 31693705dc..5c80144aec 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -9,4 +9,10 @@ interface MarketsTokenRepository { firstBatchSize: Int, nextBatchSize: Int, ): TokenListBatchFlow + + suspend fun getChart(fiatCurrencyCode: String, interval: PriceChangeInterval, tokenId: String): TokenChart + + suspend fun getTokenInfo(fiatCurrencyCode: String, tokenId: String, languageCode: String): TokenMarketInfo + + suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String): TokenQuotes } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt index 627d3f1124..bff7acf23a 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/DeleteDeprecatedLogsUseCase.kt @@ -6,7 +6,7 @@ class DeleteDeprecatedLogsUseCase( private val settingsRepository: SettingsRepository, ) { - suspend operator fun invoke() { + operator fun invoke() { settingsRepository.deleteDeprecatedLogs(maxSize = MAX_LOGS_SIZE) } diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldSaveAccessCodesUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldSaveAccessCodesUseCase.kt new file mode 100644 index 0000000000..b68ede4106 --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldSaveAccessCodesUseCase.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.settings + +import com.tangem.domain.settings.repositories.SettingsRepository + +class ShouldSaveAccessCodesUseCase(private val settingsRepository: SettingsRepository) { + + suspend operator fun invoke(): Boolean = settingsRepository.shouldSaveAccessCodes() +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index 3b5f6d3c40..06fd81fde6 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -10,11 +10,9 @@ interface SettingsRepository { suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) - @Throws - suspend fun updateAppLogs(message: String) + fun saveLogMessage(message: String) - @Throws - suspend fun deleteDeprecatedLogs(maxSize: Int) + fun deleteDeprecatedLogs(maxSize: Int) suspend fun isSendTapHelpPreviewEnabled(): Boolean diff --git a/domain/staking/build.gradle.kts b/domain/staking/build.gradle.kts index e92bc23b4c..dd1083b53b 100644 --- a/domain/staking/build.gradle.kts +++ b/domain/staking/build.gradle.kts @@ -9,7 +9,6 @@ android { namespace = "com.tangem.domain.staking" } - dependencies { api(projects.domain.staking.models) @@ -20,4 +19,8 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.features.staking.api) + + implementation(deps.tangem.blockchain) { + exclude(module = "joda-time") + } } \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingApproval.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingApproval.kt new file mode 100644 index 0000000000..52056c1f11 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingApproval.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.staking.model + +sealed class StakingApproval { + + data class Needed(val spenderAddress: String) : StakingApproval() + + data object Empty : StakingApproval() +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt index e173689c6f..f8f6b7732d 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt @@ -33,15 +33,21 @@ data class Yield( @Serializable data class Enter( val addresses: Addresses, - val args: Map, + val args: Map, ) { @Serializable data class Addresses( val address: AddressArgument, - val additionalAddresses: Map? = null, + val additionalAddresses: Map? = null, ) } + + enum class ArgType { + ADDRESS, + AMOUNT, + UNKNOWN, + } } @Serializable @@ -68,10 +74,10 @@ data class Yield( val token: Token, val tokens: List, val type: String, - val rewardSchedule: String, + val rewardSchedule: RewardSchedule, val cooldownPeriod: Period, val warmupPeriod: Period, - val rewardClaiming: String, + val rewardClaiming: RewardClaiming, val defaultValidator: String?, val minimumStake: Int?, val supportsMultipleValidators: Boolean, @@ -88,6 +94,25 @@ data class Yield( data class Enabled( val enabled: Boolean, ) + + enum class RewardSchedule { + BLOCK, + WEEK, + HOUR, + DAY, + MONTH, + ERA, + EPOCH, + + UNKNOWN, + } + + enum class RewardClaiming { + AUTO, + MANUAL, + + UNKNOWN, + } } enum class RewardType { @@ -113,6 +138,6 @@ data class Token( data class AddressArgument( val required: Boolean, val network: String? = null, - val minimum: Double? = null, - val maximum: Double? = null, + val minimum: SerializedBigDecimal? = null, + val maximum: SerializedBigDecimal? = null, ) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt index 1c521e2ab3..35072a4cb1 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt @@ -11,13 +11,13 @@ sealed class YieldBalance { fun getTotalStakingBalance(): BigDecimal { return balance.items .filterNot { it.type == BalanceType.REWARDS } - .sumOf { it.amount * it.pricePerShare } + .sumOf { it.amount } } fun getRewardStakingBalance(): BigDecimal { return balance.items .filter { it.type == BalanceType.REWARDS } - .sumOf { it.amount * it.pricePerShare } + .sumOf { it.amount } } } @@ -36,6 +36,7 @@ data class BalanceItem( val amount: BigDecimal, val pricePerShare: BigDecimal, val rawCurrencyId: String?, + val rawNetworkId: String, val validatorAddress: String?, val pendingActions: List, ) diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt index 3d3ae126da..01cc23bdc9 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt @@ -5,10 +5,13 @@ sealed class YieldBalanceList { data class Data( val balances: List, ) : YieldBalanceList() { - fun getBalance(rawCurrencyId: String?): YieldBalance { + fun getBalance(rawCurrencyId: String?, networkName: String): YieldBalance { return balances.firstOrNull { yield -> (yield as? YieldBalance.Data)?.balance?.items - ?.any { it.rawCurrencyId == rawCurrencyId } == true + ?.any { + rawCurrencyId == it.rawCurrencyId && + networkName.equals(it.rawNetworkId, ignoreCase = true) + } == true } ?: YieldBalance.Error } } diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt index 1caf4b8942..5a59ba211c 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt @@ -3,5 +3,6 @@ package com.tangem.domain.staking.model.stakekit.action enum class StakingActionCommonType { ENTER, EXIT, - PENDING, + PENDING_REWARDS, + PENDING_OTHER, } \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt index 2adfaed13b..605ed69fa5 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt @@ -12,6 +12,7 @@ data class ActionParams( val address: String, val validatorAddress: String, val token: Token, + val publicKey: String? = null, val passthrough: String? = null, val type: StakingActionType? = null, ) \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt index 381308b7a7..e5d79ff1b8 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt @@ -6,6 +6,8 @@ import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId /** * Use case for staking gas estimation. @@ -15,9 +17,13 @@ class EstimateGasUseCase( private val stakingErrorResolver: StakingErrorResolver, ) { - suspend operator fun invoke(params: ActionParams): Either { + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): Either { return Either.catch { - stakingRepository.estimateGas(params) + stakingRepository.estimateGas(userWalletId, network, params) }.mapLeft { stakingErrorResolver.resolve(it) } 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 new file mode 100644 index 0000000000..9e31da097f --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +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.StakingRepository + +class GetConstructedStakingTransactionUseCase( + private val stakingRepository: StakingRepository, + private val stakingErrorResolver: StakingErrorResolver, +) { + + suspend operator fun invoke( + networkId: String, + fee: Fee, + transactionId: String, + ): Either> = Either.catch { + stakingRepository.constructTransaction(networkId, fee, transactionId) + }.mapLeft { + stakingErrorResolver.resolve(it) + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt index 02abd643a4..16ac583d13 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt @@ -6,6 +6,8 @@ import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.delay /** @@ -16,17 +18,18 @@ class GetStakingTransactionUseCase( private val stakingErrorResolver: StakingErrorResolver, ) { - suspend operator fun invoke(params: ActionParams): Either { + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): Either> { return Either.catch { - val createAction = stakingRepository.createAction(params) + val createAction = stakingRepository.createAction(userWalletId, network, params) // workaround, sometimes transaction is not created immediately after actions/enter delay(PATCH_TRANSACTION_REQUEST_DELAY) - val createdTransaction = createAction.transactions?.get(0) ?: error("No available transaction to patch") - val patchedTransaction = stakingRepository.constructTransaction(createdTransaction.id) - - patchedTransaction + createAction.transactions ?: error("No available transaction to patch") }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/IsApproveNeededUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/IsApproveNeededUseCase.kt new file mode 100644 index 0000000000..10bc37b3fa --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/IsApproveNeededUseCase.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.StakingErrorResolver +import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.model.CryptoCurrency + +class IsApproveNeededUseCase( + private val stakingRepository: StakingRepository, + private val stakingErrorResolver: StakingErrorResolver, +) { + operator fun invoke(cryptoCurrency: CryptoCurrency): Either { + return Either + .catch { stakingRepository.getStakingApproval(cryptoCurrency) } + .mapLeft { stakingErrorResolver.resolve(it) } + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt index a9ff51285e..5b6604423e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt @@ -1,7 +1,12 @@ package com.tangem.domain.staking.repositories +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.core.lce.LceFlow -import com.tangem.domain.staking.model.* +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList @@ -15,9 +20,10 @@ import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow +@Suppress("TooManyFunctions") interface StakingRepository { - fun isStakingSupported(currencyId: String): Boolean + fun isStakingSupported(integrationKey: String): Boolean suspend fun fetchEnabledYields(refresh: Boolean) @@ -61,11 +67,15 @@ interface StakingRepository { addresses: List, ): YieldBalanceList - suspend fun createAction(params: ActionParams): StakingAction + suspend fun createAction(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingAction - suspend fun estimateGas(params: ActionParams): StakingGasEstimate + suspend fun estimateGas(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingGasEstimate - suspend fun constructTransaction(transactionId: String): StakingTransaction + suspend fun constructTransaction( + networkId: String, + fee: Fee, + transactionId: String, + ): Pair suspend fun submitHash(transactionId: String, transactionHash: String) @@ -75,4 +85,7 @@ interface StakingRepository { /** Returns whether additional staking is possible if there is already active staking */ fun isStakeMoreAvailable(networkId: Network.ID): Boolean + + /** Returns staking approval */ + fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval } \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 989a7e9c5d..bbe01868f9 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -43,4 +43,7 @@ dependencies { /** Tests */ testImplementation(deps.test.junit) testImplementation(deps.test.coroutine) + testImplementation(deps.tangem.blockchain) { + exclude(module = "joda-time") + } } \ No newline at end of file diff --git a/domain/tokens/models/build.gradle.kts b/domain/tokens/models/build.gradle.kts index c51f155acf..92950e7beb 100644 --- a/domain/tokens/models/build.gradle.kts +++ b/domain/tokens/models/build.gradle.kts @@ -1,14 +1,9 @@ plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.jvm) alias(deps.plugins.kotlin.serialization) id("configuration") } -android { - namespace = "com.tangem.domain.tokens.model" -} - dependencies { /** Project - Core */ implementation(projects.core.analytics.models) @@ -17,13 +12,7 @@ dependencies { implementation(projects.domain.txhistory.models) implementation(projects.domain.staking.models) - /** SDK dependencies */ - implementation(deps.tangem.blockchain) { - exclude(module = "joda-time") - } - /** Other dependencies */ implementation(deps.kotlin.serialization) implementation(deps.jodatime) - implementation(deps.timber) } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/NetworkAddress.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/NetworkAddress.kt index 9b57fcfe15..2e21ba5001 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/NetworkAddress.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/NetworkAddress.kt @@ -1,7 +1,5 @@ package com.tangem.domain.tokens.model -import timber.log.Timber - /** * Represents a network address configuration. */ @@ -47,11 +45,5 @@ sealed class NetworkAddress { enum class Type { Primary, Secondary, } - - init { - if (value.isEmpty()) { - Timber.w("Address value is blank") - } - } } } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt similarity index 95% rename from domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt rename to domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt index 260c9ce93f..b89306aded 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.tokens.models.analytics +package com.tangem.domain.tokens.model.analytics import com.tangem.core.analytics.models.AnalyticsEvent diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenReceiveAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt similarity index 92% rename from domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenReceiveAnalyticsEvent.kt rename to domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt index e5c1668624..f97493ffa2 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenReceiveAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.tokens.models.analytics +package com.tangem.domain.tokens.model.analytics import com.tangem.core.analytics.models.AnalyticsEvent diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt similarity index 97% rename from domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenScreenAnalyticsEvent.kt rename to domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index 171f60eb9e..1896d2c250 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.tokens.models.analytics +package com.tangem.domain.tokens.model.analytics import com.tangem.core.analytics.models.AnalyticsEvent diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenSwapPromoAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt similarity index 96% rename from domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenSwapPromoAnalyticsEvent.kt rename to domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt index 9e9156c366..6e48ed4272 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenSwapPromoAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenSwapPromoAnalyticsEvent.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.tokens.models.analytics +package com.tangem.domain.tokens.model.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/CheckHasLinkedTokensUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/CheckHasLinkedTokensUseCase.kt new file mode 100644 index 0000000000..503731cabc --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/CheckHasLinkedTokensUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId + +class CheckHasLinkedTokensUseCase( + private val currenciesRepository: CurrenciesRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either { + return Either.catch { + currenciesRepository.hasTokens(userWalletId, network) + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt index 8dee89d0e6..18f07bea3e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt @@ -145,7 +145,10 @@ internal class CurrenciesStatusesLceOperations( currencies.map { currency -> val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance(currency.id.rawCurrencyId) + val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance( + rawCurrencyId = currency.id.rawCurrencyId, + networkName = currency.network.name, + ) createCurrencyStatus( currency = currency, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt index 34e8a290cb..e82bad56f2 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt @@ -261,7 +261,10 @@ internal class CurrenciesStatusesOperations( currencies.map { currency -> val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance(currency.id.rawCurrencyId) + val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance( + rawCurrencyId = currency.id.rawCurrencyId, + networkName = currency.network.name, + ) createCurrencyStatus( currency = currency, quote = quote, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt index 4d8d4f0522..37efae4b4b 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/CurrenciesRepository.kt @@ -223,4 +223,6 @@ interface CurrenciesRepository { contractAddress: String, networkId: String, ): CryptoCurrency.Token + + suspend fun hasTokens(userWalletId: UserWalletId, network: Network): Boolean } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt index c1c3aa5e5a..8ef1dfea78 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockCurrenciesRepository.kt @@ -144,4 +144,8 @@ internal class MockCurrenciesRepository( ): CryptoCurrency.Token { error("not implemented") } + + override suspend fun hasTokens(userWalletId: UserWalletId, network: Network): Boolean { + return false + } } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt index ad8a88a0fd..33c6654e89 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt @@ -1,12 +1,18 @@ package com.tangem.domain.tokens.repository +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionStatus +import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.core.lce.lceFlow -import com.tangem.domain.staking.model.* +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata +import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus import com.tangem.domain.staking.model.stakekit.action.StakingActionType -import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.transaction.* import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency @@ -91,10 +97,10 @@ class MockStakingRepository : StakingRepository { ), tokens = listOf(), type = "auto", - rewardSchedule = "1", + rewardSchedule = Yield.Metadata.RewardSchedule.DAY, cooldownPeriod = Yield.Metadata.Period(days = 1), warmupPeriod = Yield.Metadata.Period(days = 1), - rewardClaiming = "1", + rewardClaiming = Yield.Metadata.RewardClaiming.AUTO, defaultValidator = null, minimumStake = null, supportsMultipleValidators = false, @@ -167,7 +173,11 @@ class MockStakingRepository : StakingRepository { balances = listOf(YieldBalance.Error), ) - override suspend fun createAction(params: ActionParams): StakingAction { + override suspend fun createAction( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingAction { return StakingAction( id = "quis", integrationId = "persequeris", @@ -182,7 +192,11 @@ class MockStakingRepository : StakingRepository { ) } - override suspend fun estimateGas(params: ActionParams): StakingGasEstimate { + override suspend fun estimateGas( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingGasEstimate { return StakingGasEstimate( amount = BigDecimal(0.0001), token = Token( @@ -199,7 +213,11 @@ class MockStakingRepository : StakingRepository { ) } - override suspend fun constructTransaction(transactionId: String): StakingTransaction = StakingTransaction( + override suspend fun constructTransaction( + networkId: String, + fee: Fee, + transactionId: String, + ): Pair = StakingTransaction( id = "id", network = NetworkType.SOLANA, status = StakingTransactionStatus.SIGNED, @@ -214,6 +232,9 @@ class MockStakingRepository : StakingRepository { explorerUrl = null, ledgerHwAppId = null, isMessage = false, + ) to TransactionData.Compiled( + value = TransactionData.Compiled.Data.RawString(""), + status = TransactionStatus.Unconfirmed, ) override suspend fun submitHash(transactionId: String, transactionHash: String) { @@ -229,4 +250,6 @@ class MockStakingRepository : StakingRepository { } override fun isStakeMoreAvailable(networkId: Network.ID): Boolean = true + + override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval = StakingApproval.Empty } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt index 3fb7cbf0b5..6784c1726f 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt @@ -1,11 +1,16 @@ package com.tangem.domain.transaction -import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.CommonSigner +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionExtras import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionSendResult +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigDecimal import java.math.BigInteger interface TransactionRepository { @@ -20,7 +25,18 @@ interface TransactionRepository { network: Network, txExtras: TransactionExtras?, hash: String?, - ): TransactionData.Uncompiled? + ): TransactionData.Uncompiled + + @Suppress("LongParameterList") + suspend fun createApprovalTransaction( + amount: Amount, + fee: Fee, + contractAddress: String, + spenderAddress: String, + userWalletId: UserWalletId, + network: Network, + hash: String?, + ): TransactionData.Uncompiled @Suppress("LongParameterList") suspend fun validateTransaction( @@ -49,4 +65,10 @@ interface TransactionRepository { nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras + + suspend fun getAllowance( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency.Token, + spenderAddress: String, + ): BigDecimal } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt new file mode 100644 index 0000000000..2a699deea3 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigDecimal + +class CreateApprovalTransactionUseCase( + private val transactionRepository: TransactionRepository, +) { + + @Suppress("LongParameterList") + suspend operator fun invoke( + cryptoCurrency: CryptoCurrency.Token, + userWalletId: UserWalletId, + amount: BigDecimal, + fee: Fee, + contractAddress: String, + spenderAddress: String, + hash: String? = null, + ) = Either.catch { + transactionRepository.createApprovalTransaction( + amount = amount.convertToSdkAmount(cryptoCurrency), + contractAddress = contractAddress, + spenderAddress = spenderAddress, + userWalletId = userWalletId, + network = cryptoCurrency.network, + fee = fee, + hash = hash, + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt index ab5172eaf8..87f10ea630 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt @@ -26,17 +26,15 @@ class CreateTransactionUseCase( txExtras: TransactionExtras? = null, hash: String? = null, ) = Either.catch { - requireNotNull( - transactionRepository.createTransaction( - amount = amount, - fee = fee, - memo = memo, - destination = destination, - userWalletId = userWalletId, - network = network, - txExtras = txExtras, - hash = hash, - ), - ) { "Failed to create transaction" } + transactionRepository.createTransaction( + amount = amount, + fee = fee, + memo = memo, + destination = destination, + userWalletId = userWalletId, + network = network, + txExtras = txExtras, + hash = hash, + ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt new file mode 100644 index 0000000000..316a1061ef --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigDecimal + +class GetAllowanceUseCase( + private val transactionRepository: TransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + spenderAddress: String, + ): Either { + return Either.catch { + transactionRepository.getAllowance( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency as CryptoCurrency.Token, + spenderAddress = spenderAddress, + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/api/build.gradle.kts b/features/manage-tokens/api/build.gradle.kts index 0cf94d3840..8bbbd4e02b 100644 --- a/features/manage-tokens/api/build.gradle.kts +++ b/features/manage-tokens/api/build.gradle.kts @@ -15,4 +15,7 @@ dependencies { /* Project - Core */ implementation(projects.core.ui) implementation(projects.core.decompose) + + /* Compose */ + implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/ManageTokensToggles.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/ManageTokensToggles.kt new file mode 100644 index 0000000000..09ad4a0dcd --- /dev/null +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/ManageTokensToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.managetokens + +interface ManageTokensToggles { + val isFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt new file mode 100644 index 0000000000..761758d465 --- /dev/null +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.managetokens.component + +import androidx.compose.runtime.Composable +import com.tangem.domain.wallets.models.UserWalletId + +interface AddCustomTokenComponent { + + @Composable + fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory { + fun create(params: Params): AddCustomTokenComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt index 54cb358614..a38a96267c 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt @@ -2,13 +2,11 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.wallets.models.UserWalletId interface ManageTokensComponent : ComposableContentComponent { - data class Params( - val userWalletId: UserWalletId, - ) + data class Params(val mode: Mode) + enum class Mode { READ_ONLY, MANAGE, } interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 8e3cc2bddb..1305e90261 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -19,6 +19,11 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.common.routing) + implementation(projects.core.featuretoggles) + + /* Project - Domain */ + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) /* AndroidX */ implementation(deps.androidx.activity.compose) @@ -28,6 +33,7 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.compose.foundation) + implementation(deps.compose.material) // For button colors implementation(deps.compose.material3) implementation(deps.compose.shimmer) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/DefaultManageTokensToggles.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/DefaultManageTokensToggles.kt new file mode 100644 index 0000000000..ea923c7772 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/DefaultManageTokensToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.managetokens + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager + +internal class DefaultManageTokensToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : ManageTokensToggles { + + override val isFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("NEW_MANAGE_TOKENS") +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt new file mode 100644 index 0000000000..acb8dc42a4 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt @@ -0,0 +1,19 @@ +package com.tangem.features.managetokens.component + +import androidx.compose.foundation.lazy.LazyListScope +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +internal interface CustomTokenFormComponent { + + fun content(scope: LazyListScope) + + data class Params( + val userWalletId: UserWalletId, + val networkId: Network.ID, + ) + + interface Factory { + fun create(params: Params): CustomTokenFormComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt new file mode 100644 index 0000000000..a2551d430c --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.managetokens.component + +import androidx.compose.foundation.lazy.LazyListScope +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.entity.SelectedNetworkUM + +internal interface CustomTokenNetworkSelectorComponent { + + fun content(scope: LazyListScope) + + data class Params( + val userWalletId: UserWalletId, + val selectedNetwork: SelectedNetworkUM?, + val onNetworkSelected: (SelectedNetworkUM) -> Unit, + ) + + interface Factory { + fun create(params: Params): CustomTokenNetworkSelectorComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt new file mode 100644 index 0000000000..e89139cf5f --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.managetokens.component.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.model.ManageTokensModel +import com.tangem.features.managetokens.ui.ManageTokensScreen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultManageTokensComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: ManageTokensComponent.Params, +) : ManageTokensComponent, AppComponentContext by context { + + private val model: ManageTokensModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + ManageTokensScreen( + modifier = modifier, + state = state, + ) + } + + @AssistedFactory + interface Factory : ManageTokensComponent.Factory { + override fun create( + context: AppComponentContext, + params: ManageTokensComponent.Params, + ): DefaultManageTokensComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt new file mode 100644 index 0000000000..b428c0ed2d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt @@ -0,0 +1,84 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM +import com.tangem.features.managetokens.entity.AddCustomTokenUM +import com.tangem.features.managetokens.entity.ClickableFieldUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +internal class PreviewAddCustomTokenComponent( + initialState: AddCustomTokenUM = AddCustomTokenUM.NetworkSelector(popBack = {}), +) : AddCustomTokenComponent { + + private val userWalletId = UserWalletId(stringValue = "321") + + private val previewState: MutableStateFlow = MutableStateFlow(initialState) + + @Composable + override fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) { + val state by previewState.collectAsStateWithLifecycle() + val config = TangemBottomSheetConfig( + isShow = isVisible, + onDismissRequest = onDismiss, + content = state, + ) + + AddCustomTokenBottomSheet( + config = config, + content = { + when (val s = state) { + is AddCustomTokenUM.Form -> { + PreviewCustomTokenFormComponent( + networkName = ClickableFieldUM( + label = resourceReference(R.string.custom_token_network_input_title), + value = stringReference(s.selectedNetwork.name), + onClick = { showNetworkSelector(s.selectedNetwork) }, + ), + ).content(this) + } + is AddCustomTokenUM.NetworkSelector -> { + PreviewCustomTokenNetworkSelectorComponent( + params = CustomTokenNetworkSelectorComponent.Params( + userWalletId = userWalletId, + selectedNetwork = s.selectedNetwork, + onNetworkSelected = ::showForm, + ), + networksSize = 20, + ).content(this) + } + } + }, + ) + } + + private fun showNetworkSelector(selectedNetwork: SelectedNetworkUM) { + previewState.update { + AddCustomTokenUM.NetworkSelector(selectedNetwork, popBack = { showForm(selectedNetwork) }) + } + } + + private fun showForm(network: SelectedNetworkUM) { + previewState.update { + AddCustomTokenUM.Form( + popBack = {}, + selectedNetwork = network, + addTokenButton = AddCustomTokenButtonUM.Visible( + isEnabled = false, + onClick = {}, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt new file mode 100644 index 0000000000..625090f28d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt @@ -0,0 +1,81 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.foundation.lazy.LazyListScope +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.features.managetokens.component.CustomTokenFormComponent +import com.tangem.features.managetokens.entity.ClickableFieldUM +import com.tangem.features.managetokens.entity.CustomTokenFormUM +import com.tangem.features.managetokens.entity.TextInputFieldUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.customTokenFormContent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class PreviewCustomTokenFormComponent( + networkName: ClickableFieldUM = ClickableFieldUM( + label = resourceReference(R.string.custom_token_network_input_title), + value = stringReference(value = "Ethereum"), + onClick = {}, + ), + derivationPath: ClickableFieldUM = ClickableFieldUM( + label = resourceReference(R.string.custom_token_derivation_path), + value = stringReference(value = "Default"), + onClick = {}, + ), + canAddToken: Boolean = false, + contractAddress: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_contract_address_input_title), + placeholder = stringReference(value = "0x000000000000000000000000000"), + value = "", + onValueChange = {}, + ), + tokenName: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_name_input_title), + placeholder = stringReference(value = "E.g. USD Coin"), + value = "", + onValueChange = {}, + ), + tokenSymbol: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_token_symbol_input_title), + placeholder = stringReference(value = "E.g. USDC"), + value = "", + onValueChange = {}, + ), + tokenDecimals: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_decimals_input_title), + placeholder = stringReference(value = "8"), + value = "", + onValueChange = {}, + ), + notifications: ImmutableList = persistentListOf( + CustomTokenFormUM.NotificationUM( + id = "1", + config = NotificationConfig( + title = stringReference(value = "Note that tokens can be created by anyone"), + subtitle = stringReference(value = "Be aware of adding scam tokens, they can cost nothing"), + iconResId = R.drawable.img_attention_20, + ), + ), + ), +) : CustomTokenFormComponent { + + private val previewState = CustomTokenFormUM( + networkName = networkName, + contractAddress = contractAddress, + tokenName = tokenName, + tokenSymbol = tokenSymbol, + tokenDecimals = tokenDecimals, + derivationPath = derivationPath, + notifications = notifications, + canAddToken = canAddToken, + onDerivationPathClick = {}, + onNetworkClick = {}, + onAddClick = {}, + ) + + override fun content(scope: LazyListScope) { + scope.customTokenFormContent(model = previewState) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt new file mode 100644 index 0000000000..620dbe1222 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt @@ -0,0 +1,50 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.foundation.lazy.LazyListScope +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.entity.CurrencyNetworkUM +import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.customTokenNetworkSelectorContent +import kotlinx.collections.immutable.toImmutableList + +internal class PreviewCustomTokenNetworkSelectorComponent( + private val params: CustomTokenNetworkSelectorComponent.Params = CustomTokenNetworkSelectorComponent.Params( + userWalletId = UserWalletId(stringValue = "321"), + selectedNetwork = null, + onNetworkSelected = {}, + ), + networksSize: Int = 5, +) : CustomTokenNetworkSelectorComponent { + + private val previewNetworks = List(size = networksSize) { networkIndex -> + val n = SelectedNetworkUM( + id = Network.ID(networkIndex.toString()), + name = "Network $networkIndex", + ) + + CurrencyNetworkUM( + id = n.id, + name = n.name, + type = "N$networkIndex", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = false, + isSelected = n.id == params.selectedNetwork?.id, + onSelectedStateChange = { params.onNetworkSelected(n) }, + ) + }.toImmutableList() + + private val previewState = CustomTokenNetworkSelectorUM( + showTitle = params.selectedNetwork == null, + networks = previewNetworks, + ) + + override fun content(scope: LazyListScope) { + scope.customTokenNetworkSelectorContent( + model = previewState, + ) + } +} \ No newline at end of file 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 8fabf7cfd4..980afcb895 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 @@ -6,15 +6,14 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.core.ui.components.rows.model.ChainRowUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.tokens.model.Network import com.tangem.features.managetokens.component.ManageTokensComponent -import com.tangem.features.managetokens.entity.CurrencyItemUM -import com.tangem.features.managetokens.entity.CurrencyNetworkUM -import com.tangem.features.managetokens.entity.ManageTokensUM +import com.tangem.features.managetokens.entity.* import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.ui.ManageTokensScreen import kotlinx.collections.immutable.mutate @@ -28,11 +27,18 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { private val changedItemsIds: MutableSet = mutableSetOf() private var items = initItems() - private val previewState = MutableStateFlow( - value = ManageTokensUM( + value = ManageTokensUM.ManageContent( popBack = {}, items = items, + topBar = ManageTokensTopBarUM.ManageContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = {}, + endButton = TopAppBarButtonUM( + iconRes = R.drawable.ic_plus_24, + onIconClicked = {}, + ), + ), search = SearchBarUM( placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), query = "", @@ -41,8 +47,8 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { onActiveChange = ::toggleSearchBar, ), hasChanges = false, + isLoading = false, onSaveClick = {}, - onAddCustomToken = {}, ), ) @@ -129,14 +135,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex -> CurrencyNetworkUM( - id = networkIndex.toString(), - model = BlockchainRowUM( - name = "NETWORK$networkIndex", - type = "N$networkIndex", - iconResId = R.drawable.ic_eth_16, - isMainNetwork = networkIndex == 0, - isSelected = false, - ), + id = Network.ID(networkIndex.toString()), + name = "NETWORK$networkIndex", + type = "N$networkIndex", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = networkIndex == 0, isSelected = false, onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) }, ) @@ -171,14 +174,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { it.fastForEachIndexed { index, network -> if (index == networkIndex) { it[index] = network.copy( - model = network.model.copy( - iconResId = if (isSelected) { - R.drawable.img_eth_22 - } else { - R.drawable.ic_eth_16 - }, - isSelected = isSelected, - ), + iconResId = if (isSelected) { + R.drawable.img_eth_22 + } else { + R.drawable.ic_eth_16 + }, isSelected = isSelected, ) } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt new file mode 100644 index 0000000000..f84b3c4c8e --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.managetokens.di + +import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.impl.DefaultManageTokensComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/FeatureModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/FeatureModule.kt new file mode 100644 index 0000000000..9c181b867e --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/FeatureModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.managetokens.di + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager +import com.tangem.features.managetokens.DefaultManageTokensToggles +import com.tangem.features.managetokens.ManageTokensToggles +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 FeatureModule { + + @Provides + @Singleton + fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): ManageTokensToggles = + DefaultManageTokensToggles(featureTogglesManager = featureTogglesManager) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt new file mode 100644 index 0000000000..572c2624cb --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.managetokens.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.managetokens.model.ManageTokensModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(ManageTokensModel::class) + fun provideManageTokensModel(model: ManageTokensModel): Model +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt new file mode 100644 index 0000000000..a34939d61f --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt @@ -0,0 +1,54 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.domain.tokens.model.Network + +@Immutable +internal sealed class AddCustomTokenUM : TangemBottomSheetConfigContent { + + abstract val selectedNetwork: SelectedNetworkUM? + abstract val addTokenButton: AddCustomTokenButtonUM + + abstract val popBack: () -> Unit + + data class NetworkSelector( + override val selectedNetwork: SelectedNetworkUM? = null, + override val popBack: () -> Unit, + ) : AddCustomTokenUM() { + + override val addTokenButton: AddCustomTokenButtonUM = AddCustomTokenButtonUM.Hidden + } + + data class Form( + override val selectedNetwork: SelectedNetworkUM, + override val addTokenButton: AddCustomTokenButtonUM.Visible, + override val popBack: () -> Unit, + ) : AddCustomTokenUM() +} + +@Immutable +internal data class SelectedNetworkUM( + val id: Network.ID, + val name: String, +) + +@Immutable +internal sealed class AddCustomTokenButtonUM { + + open val onClick: () -> Unit = {} + + open val isEnabled: Boolean = false + + val isVisible: Boolean + get() = this is Visible + + data object Hidden : AddCustomTokenButtonUM() { + override val onClick: () -> Unit = {} + } + + data class Visible( + override val isEnabled: Boolean, + override val onClick: () -> Unit, + ) : AddCustomTokenButtonUM() +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt index 8ed8dc5f9d..477585db3f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt @@ -1,12 +1,15 @@ package com.tangem.features.managetokens.entity import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.tokens.model.Network @Immutable internal data class CurrencyNetworkUM( - val id: String, - val model: BlockchainRowUM, + val id: Network.ID, + val name: String, + val type: String, + val iconResId: Int, + val isMainNetwork: Boolean, val isSelected: Boolean, val onSelectedStateChange: (Boolean) -> Unit, ) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt new file mode 100644 index 0000000000..ea9bfbceb0 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt @@ -0,0 +1,44 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class CustomTokenFormUM( + val networkName: ClickableFieldUM, + val contractAddress: TextInputFieldUM, + val tokenName: TextInputFieldUM, + val tokenSymbol: TextInputFieldUM, + val tokenDecimals: TextInputFieldUM, + val derivationPath: ClickableFieldUM, + val notifications: ImmutableList, + val canAddToken: Boolean, + val onNetworkClick: () -> Unit, + val onDerivationPathClick: () -> Unit, + val onAddClick: () -> Unit, +) { + + @Immutable + data class NotificationUM( + val id: String, + val config: NotificationConfig, + ) +} + +@Immutable +internal data class TextInputFieldUM( + val label: TextReference, + val placeholder: TextReference, + val value: String, + val onValueChange: (String) -> Unit, + val error: TextReference? = null, +) + +@Immutable +internal data class ClickableFieldUM( + val label: TextReference, + val value: TextReference, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt new file mode 100644 index 0000000000..1435d25f7d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class CustomTokenNetworkSelectorUM( + val showTitle: Boolean, + val networks: ImmutableList, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt new file mode 100644 index 0000000000..b2f08a0ef0 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed class ManageTokensTopBarUM { + + abstract val title: TextReference + abstract val onBackButtonClick: () -> Unit + + data class ReadContent( + override val title: TextReference, + override val onBackButtonClick: () -> Unit, + ) : ManageTokensTopBarUM() + + data class ManageContent( + override val title: TextReference, + override val onBackButtonClick: () -> Unit, + val endButton: TopAppBarButtonUM, + ) : ManageTokensTopBarUM() +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt index c8678bdd38..d53e7e5e4d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt @@ -5,11 +5,40 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import kotlinx.collections.immutable.ImmutableList @Immutable -internal data class ManageTokensUM( - val popBack: () -> Unit, - val items: ImmutableList, - val search: SearchBarUM, - val hasChanges: Boolean, - val onAddCustomToken: () -> Unit, - val onSaveClick: () -> Unit, -) \ No newline at end of file +internal sealed class ManageTokensUM { + + abstract val popBack: () -> Unit + abstract val isLoading: Boolean + abstract val items: ImmutableList + abstract val topBar: ManageTokensTopBarUM + abstract val search: SearchBarUM + + data class ReadContent( + override val popBack: () -> Unit, + override val isLoading: Boolean, + override val items: ImmutableList, + override val topBar: ManageTokensTopBarUM, + override val search: SearchBarUM, + ) : ManageTokensUM() + + data class ManageContent( + override val popBack: () -> Unit, + override val isLoading: Boolean, + override val items: ImmutableList, + override val topBar: ManageTokensTopBarUM, + override val search: SearchBarUM, + val onSaveClick: () -> Unit, + val hasChanges: Boolean, + ) : ManageTokensUM() + + fun copySealed( + search: SearchBarUM = this.search, + items: ImmutableList = this.items, + hasChanges: Boolean = this is ManageContent && this.hasChanges, + ): ManageTokensUM { + return when (this) { + is ManageContent -> copy(search = search, items = items, hasChanges = hasChanges) + is ReadContent -> copy(search = search, items = items) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6141538abd --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -0,0 +1,243 @@ +package com.tangem.features.managetokens.model + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.entity.* +import com.tangem.features.managetokens.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ComponentScoped +internal class ManageTokensModel @Inject constructor( + paramsContainer: ParamsContainer, + private val router: Router, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params: ManageTokensComponent.Params = paramsContainer.require() + private val changedItemsIds: MutableSet = mutableSetOf() + private var items = initItems() + + val state: MutableStateFlow = MutableStateFlow(value = getInitialState(mode = params.mode)) + + private fun getInitialState(mode: ManageTokensComponent.Mode): ManageTokensUM { + return when (mode) { + ManageTokensComponent.Mode.READ_ONLY -> createReadContentModel() + ManageTokensComponent.Mode.MANAGE -> createManageContentModel() + } + } + + private fun createReadContentModel(): ManageTokensUM.ReadContent { + return ManageTokensUM.ReadContent( + popBack = router::pop, + isLoading = false, + items = initItems(), + topBar = ManageTokensTopBarUM.ReadContent( + title = resourceReference(R.string.common_search_tokens), + onBackButtonClick = router::pop, + ), + search = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = "", + onQueryChange = ::searchCurrencies, + isActive = false, + onActiveChange = ::toggleSearchBar, + ), + ) + } + + private fun createManageContentModel(): ManageTokensUM.ManageContent { + return ManageTokensUM.ManageContent( + popBack = router::pop, + isLoading = false, + items = initItems(), + topBar = ManageTokensTopBarUM.ManageContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = router::pop, + endButton = TopAppBarButtonUM( + iconRes = R.drawable.ic_plus_24, + onIconClicked = ::onAddCustomToken, + ), + ), + search = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = "", + onQueryChange = ::searchCurrencies, + isActive = false, + onActiveChange = ::toggleSearchBar, + ), + onSaveClick = ::onSaveClick, + hasChanges = false, + ) + } + + private fun onAddCustomToken() { + // TODO: [REDACTED_JIRA] + } + + private fun onSaveClick() { + // TODO: [REDACTED_JIRA] + } + + @Suppress("UnusedPrivateMember") + private fun searchCurrencies(query: String) { + // TODO: [REDACTED_JIRA] + val newItems = if (query.isBlank()) { + initItems() + } else { + state.value.items.filter { currency -> + currency.model.name.contains(query, ignoreCase = true) + }.toPersistentList() + } + state.update { state -> + state.copySealed(search = state.search.copy(query = query), items = newItems) + } + } + + private fun toggleSearchBar(isActive: Boolean) { + state.update { state -> + state.copySealed( + search = state.search.copy(isActive = isActive), + ) + } + } + + private fun initItems() = List(size = 30) { index -> + if (index < 2) { + getCustomItem(index) + } else { + getBasicItem(index) + } + }.toPersistentList() + + private fun getCustomItem(index: Int) = CurrencyItemUM.Custom( + id = index.toString(), + model = ChainRowUM( + name = "Custom token $index", + type = "CT$index", + icon = CurrencyIconState.CustomTokenIcon( + tint = Color.White, + background = Color.Black, + topBadgeIconResId = R.drawable.img_eth_22, + isGrayscale = false, + showCustomBadge = true, + ), + showCustom = true, + ), + onRemoveClick = {}, + ) + + private fun getBasicItem(index: Int) = CurrencyItemUM.Basic( + id = index.toString(), + model = ChainRowUM( + name = "Currency $index", + type = "C$index", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_btc_22, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ), + networks = if (index == 2) { + CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index)) + } else { + CurrencyItemUM.Basic.NetworksUM.Collapsed + }, + onExpandClick = { toggleCurrency(index) }, + ) + + private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex -> + CurrencyNetworkUM( + id = Network.ID(networkIndex.toString()), + name = "NETWORK$networkIndex", + type = "N$networkIndex", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = networkIndex == 0, + isSelected = false, + onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) }, + ) + }.toImmutableList() + + private fun toggleCurrency(index: Int) { + val updatedItem = when (val item = items[index]) { + is CurrencyItemUM.Basic -> item.copy( + networks = if (item.networks is CurrencyItemUM.Basic.NetworksUM.Collapsed) { + CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index)) + } else { + CurrencyItemUM.Basic.NetworksUM.Collapsed + }, + ) + is CurrencyItemUM.Custom -> return + } + + state.update { state -> + items = items.mutate { + it[index] = updatedItem + } + state.copySealed(items = items) + } + } + + private fun toggleNetwork(currencyIndex: Int, networkIndex: Int, isSelected: Boolean) { + val updatedItem = when (val item = items[currencyIndex]) { + is CurrencyItemUM.Basic -> { + val updatedNetworks = (item.networks as? CurrencyItemUM.Basic.NetworksUM.Expanded) + ?.copy( + networks = item.networks.networks.toPersistentList().mutate { + it.fastForEachIndexed { index, network -> + if (index == networkIndex) { + it[index] = network.copy( + iconResId = if (isSelected) { + R.drawable.img_eth_22 + } else { + R.drawable.ic_eth_16 + }, + isSelected = isSelected, + ) + } + } + }, + ) + ?: return + + item.copy(networks = updatedNetworks) + } + is CurrencyItemUM.Custom -> return + } + + val id = "${currencyIndex}_$networkIndex" + if (changedItemsIds.contains(id)) { + changedItemsIds.remove(id) + } else { + changedItemsIds.add(id) + } + + state.update { state -> + items = items.mutate { + it[currencyIndex] = updatedItem + } + state.copySealed( + items = items, + hasChanges = changedItemsIds.isNotEmpty(), + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt new file mode 100644 index 0000000000..a002b54b4c --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt @@ -0,0 +1,188 @@ +package com.tangem.features.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.isOpened +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent +import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM +import com.tangem.features.managetokens.entity.AddCustomTokenUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig, content: LazyListScope.() -> Unit) { + TangemBottomSheet( + config = config, + title = { model -> + Title(model) + }, + containerColor = TangemTheme.colors.background.secondary, + content = { model -> + Content( + model = model, + content = content, + ) + }, + ) +} + +@Composable +private fun Title(model: AddCustomTokenUM, modifier: Modifier = Modifier) { + val showTokenNetworkTitle = model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null + + if (showTokenNetworkTitle) { + TangemTopAppBar( + modifier = modifier, + title = resourceReference(R.string.custom_token_network_selector_title), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(model.popBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) + } else { + TangemBottomSheetTitle( + modifier = modifier, + title = resourceReference(R.string.add_custom_token_title), + ) + } +} + +@Composable +private fun Content(model: AddCustomTokenUM, content: LazyListScope.() -> Unit, modifier: Modifier = Modifier) { + val density = LocalDensity.current + val keyboardState by keyboardAsState() + + var fabHeight by remember { mutableStateOf(0.dp) } + + Scaffold( + modifier = modifier.imePadding(), + containerColor = TangemTheme.colors.background.secondary, + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + AnimatedVisibility( + modifier = Modifier.onSizeChanged { + fabHeight = with(density) { it.height.toDp() } + }, + visible = model.addTokenButton.isVisible && !keyboardState.isOpened, + enter = fadeIn(), + exit = fadeOut(), + label = "Add button visibility", + ) { + PrimaryButton( + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + text = stringResource(id = R.string.custom_token_add_token), + enabled = model.addTokenButton.isEnabled, + onClick = model.addTokenButton.onClick, + ) + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier.padding(paddingValues), + contentPadding = PaddingValues( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32 + fabHeight, + ), + ) { + item { + if (model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null) { + Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing12)) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = TangemTheme.dimens.spacing16), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + text = stringResource(id = R.string.custom_token_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + } + } + } + + content() + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_AddCustomTokenBottomSheet( + @PreviewParameter(AddCustomTokenComponentPreviewProvider::class) component: AddCustomTokenComponent, +) { + TangemThemePreview { + component.BottomSheet(isVisible = true, onDismiss = {}) + } +} + +private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewAddCustomTokenComponent(), + PreviewAddCustomTokenComponent( + initialState = AddCustomTokenUM.NetworkSelector( + popBack = {}, + selectedNetwork = SelectedNetworkUM( + id = Network.ID(value = "0"), + name = "Ethereum", + ), + ), + ), + PreviewAddCustomTokenComponent( + initialState = AddCustomTokenUM.Form( + popBack = {}, + selectedNetwork = SelectedNetworkUM( + id = Network.ID(value = "1"), + name = "Ethereum", + ), + addTokenButton = AddCustomTokenButtonUM.Visible( + isEnabled = false, + onClick = {}, + ), + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt new file mode 100644 index 0000000000..e898e44150 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt @@ -0,0 +1,196 @@ +package com.tangem.features.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.fields.SimpleTextField +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.managetokens.component.preview.PreviewCustomTokenFormComponent +import com.tangem.features.managetokens.entity.ClickableFieldUM +import com.tangem.features.managetokens.entity.CustomTokenFormUM +import com.tangem.features.managetokens.entity.TextInputFieldUM + +internal fun LazyListScope.customTokenFormContent(model: CustomTokenFormUM) { + item { + ClickableField( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + model = model.networkName, + ) + } + + item { + Column( + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing12) + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) { + TextField( + model = model.contractAddress, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = model.tokenName, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = model.tokenSymbol, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = model.tokenDecimals, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + ) + } + } + + item { + ClickableField( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + model = model.derivationPath, + ) + } + + items( + items = model.notifications, + key = { it.id }, + ) { notification -> + Notification( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + config = notification.config, + containerColor = TangemTheme.colors.button.disabled, + ) + } +} + +@Composable +private fun TextField( + model: TextInputFieldUM, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + InformationBlock( + modifier = modifier, + title = { + val color by animateColorAsState( + targetValue = if (model.error != null) { + TangemTheme.colors.text.warning + } else { + TangemTheme.colors.text.tertiary + }, + label = "Field label color", + ) + + Text( + text = (model.error ?: model.label).resolveReference(), + style = TangemTheme.typography.subtitle2, + color = color, + ) + }, + content = { + SimpleTextField( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + value = model.value, + onValueChange = model.onValueChange, + readOnly = false, + placeholder = model.placeholder, + singleLine = true, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + }, + ) +} + +@Composable +private fun ClickableField(model: ClickableFieldUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .clickable(onClick = model.onClick), + title = { + Text( + text = model.label.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + content = { + Text( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + text = model.value.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + }, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_CustomTokenFormContent( + @PreviewParameter(PreviewCustomTokenFormComponentProvider::class) + component: PreviewCustomTokenFormComponent, +) { + TangemThemePreview { + LazyColumn( + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) { component.content(scope = this) } + } +} + +private class PreviewCustomTokenFormComponentProvider : + PreviewParameterProvider { + + override val values: Sequence + get() = sequenceOf( + PreviewCustomTokenFormComponent(), + PreviewCustomTokenFormComponent( + contractAddress = TextInputFieldUM( + label = stringReference("Contract address"), + value = "0x1234567890", + error = stringReference("Contract address is invalid"), + placeholder = stringReference("0x1234567890"), + onValueChange = {}, + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt new file mode 100644 index 0000000000..57abdb15b5 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt @@ -0,0 +1,158 @@ +package com.tangem.features.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +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.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.rows.ChainRow +import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.component.preview.PreviewCustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.entity.CurrencyNetworkUM +import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R + +internal fun LazyListScope.customTokenNetworkSelectorContent(model: CustomTokenNetworkSelectorUM) { + val lastIndex = model.networks.lastIndex + + if (model.showTitle) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size36) + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.bottomSheet, + ), + ) { + Text( + modifier = Modifier + .padding( + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing6, + ) + .padding(horizontal = TangemTheme.dimens.spacing12), + text = stringResource(R.string.add_custom_token_choose_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + + itemsIndexed( + items = model.networks, + key = { _, item -> item.id.value }, + ) { index, item -> + NetworkItem( + modifier = Modifier + .fillMaxWidth() + .clip( + shape = when { + !model.showTitle && index == 0 -> RoundedCornerShape( + topStart = TangemTheme.dimens.radius16, + topEnd = TangemTheme.dimens.radius16, + ) + index == lastIndex -> RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ) + else -> RectangleShape + }, + ) + .background(color = TangemTheme.colors.background.primary) + .clickable(onClick = { item.onSelectedStateChange(true) }) + .padding(horizontal = TangemTheme.dimens.spacing4), + model = item, + ) + } +} + +@Composable +private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) { + ChainRow( + modifier = modifier, + model = with(model) { + ChainRowUM( + name = name, + type = type, + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = model.iconResId, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ) + }, + action = { + AnimatedVisibility( + modifier = Modifier.size(TangemTheme.dimens.size24), + visible = model.isSelected, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + }, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_CustomTokenNetworkSelectorContent( + @PreviewParameter(CustomTokenNetworkSelectorComponentPreviewProvider::class) + component: CustomTokenNetworkSelectorComponent, +) { + TangemThemePreview { + LazyColumn { + component.content(this) + } + } +} + +private class CustomTokenNetworkSelectorComponentPreviewProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewCustomTokenNetworkSelectorComponent(), + PreviewCustomTokenNetworkSelectorComponent( + params = CustomTokenNetworkSelectorComponent.Params( + userWalletId = UserWalletId(stringValue = "321"), + selectedNetwork = SelectedNetworkUM( + id = Network.ID(value = "0"), + name = "", + ), + onNetworkSelected = {}, + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt index 63a14af8d4..9f9584fac9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold @@ -35,12 +36,15 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.rows.ArrowRow import com.tangem.core.ui.components.rows.BlockchainRow import com.tangem.core.ui.components.rows.ChainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent import com.tangem.features.managetokens.entity.CurrencyItemUM import com.tangem.features.managetokens.entity.CurrencyItemUM.Basic.NetworksUM +import com.tangem.features.managetokens.entity.ManageTokensTopBarUM import com.tangem.features.managetokens.entity.ManageTokensUM import com.tangem.features.managetokens.impl.R import kotlinx.collections.immutable.ImmutableList @@ -56,14 +60,9 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi modifier = modifier, containerColor = TangemTheme.colors.background.primary, topBar = { - TangemTopAppBar( + ManageTokensTopBar( modifier = Modifier.statusBarsPadding(), - title = stringResource(id = R.string.main_manage_tokens), - startButton = TopAppBarButtonUM.Back(state.popBack), - endButton = TopAppBarButtonUM( - iconRes = R.drawable.ic_plus_24, - onIconClicked = state.onAddCustomToken, - ), + topBar = state.topBar, ) }, content = { innerPadding -> @@ -71,18 +70,36 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi modifier = Modifier .padding(innerPadding) .fillMaxSize(), - state = state, + search = state.search, + items = state.items, + isLoading = state.isLoading, + hasChanges = state is ManageTokensUM.ManageContent && state.hasChanges, ) }, floatingActionButtonPosition = FabPosition.Center, floatingActionButton = { - SaveChangesButton( - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - isVisible = state.hasChanges, - onClick = state.onSaveClick, - ) + if (state is ManageTokensUM.ManageContent) { + SaveChangesButton( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + isVisible = state.hasChanges, + onClick = state.onSaveClick, + ) + } + }, + ) +} + +@Composable +private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, modifier: Modifier = Modifier) { + TangemTopAppBar( + modifier = modifier, + title = topBar.title.resolveReference(), + startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick), + endButton = when (topBar) { + is ManageTokensTopBarUM.ManageContent -> topBar.endButton + is ManageTokensTopBarUM.ReadContent -> null }, ) } @@ -105,24 +122,48 @@ private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier: } @Composable -private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) { +private fun LoadingContent() { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = TangemTheme.colors.background.primary), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = TangemTheme.colors.icon.accent) + } +} + +@Composable +private fun Content( + search: SearchBarUM, + items: ImmutableList, + isLoading: Boolean, + hasChanges: Boolean, + modifier: Modifier = Modifier, +) { Box(modifier = modifier) { Currencies( modifier = Modifier.fillMaxSize(), - items = state.items, - search = state.search, + items = items, + search = search, ) AnimatedVisibility( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth(), - visible = state.hasChanges, + visible = hasChanges, label = "bottom_fade_visibility", ) { BottomFade() } } + + Crossfade(targetState = isLoading, label = "ManageTokensLoadingContent") { + if (it) { + LoadingContent() + } + } } @OptIn(ExperimentalFoundationApi::class) @@ -245,7 +286,15 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod isLastItem = index == currentItems.lastIndex, content = { BlockchainRow( - model = network.model, + model = with(network) { + BlockchainRowUM( + name = name, + type = type, + iconResId = iconResId, + isMainNetwork = isMainNetwork, + isSelected = isSelected, + ) + }, action = { TangemSwitch( checked = network.isSelected, diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsListComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt similarity index 82% rename from features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsListComponent.kt rename to features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt index c0d14dbea5..21ec5c5772 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsListComponent.kt +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.unit.Dp import com.tangem.core.decompose.context.AppComponentContext @Stable -interface MarketsListComponent { +interface MarketsEntryComponent { @Composable fun BottomSheetContent( @@ -18,6 +18,6 @@ interface MarketsListComponent { ) interface Factory { - fun create(context: AppComponentContext): MarketsListComponent + fun create(context: AppComponentContext): MarketsEntryComponent } } \ No newline at end of file diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 46bc8c674a..7d8b7d0667 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { /* Project - API */ api(projects.features.markets.api) + implementation(projects.core.navigation) /* Domain */ implementation(projects.domain.markets) @@ -23,6 +24,7 @@ dependencies { /* Compose */ implementation(deps.compose.coil) implementation(deps.compose.foundation) + implementation(deps.compose.material) implementation(deps.compose.material3) implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) @@ -37,6 +39,7 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) implementation(deps.timber) + implementation(deps.decompose.ext.compose) /* Core */ implementation(projects.core.decompose) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt new file mode 100644 index 0000000000..a166e55e22 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt @@ -0,0 +1,164 @@ +package com.tangem.features.markets + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.* +import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState +import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.value.Value +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.api.toSerializable +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsEntryComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + private val marketsEntryChildFactory: MarketsEntryChildFactory, +) : MarketsEntryComponent, AppComponentContext by context { + + private val stackNavigation = StackNavigation() + + val stack: Value> = childStack( + key = "main", + source = stackNavigation, + serializer = MarketsEntryChildFactory.Child.serializer(), + initialConfiguration = MarketsEntryChildFactory.Child.TokenList, + handleBackButton = true, + childFactory = { configuration, componentContext -> + marketsEntryChildFactory.createChild( + child = configuration, + appComponentContext = childByContext(componentContext), + onTokenSelected = ::marketsListTokenSelected, + onDetailsBack = ::onDetailsBack, + ) + }, + ) + + @Suppress("LongMethod") + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + val primary = TangemTheme.colors.background.primary + val secondary = TangemTheme.colors.background.secondary + val backgroundColor = remember { Animatable(primary) } + val stackState = stack.subscribeAsState() + + LocalMainBottomSheetColor.current.value = backgroundColor.value + + Children( + stack = stackState.value, + animation = stackAnimation(slide()), + ) { + when (it.configuration) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + (it.instance as MarketsTokenListComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + } + } + + // order of LaunchedEffects is important here + + val activeChild = stackState.value.active.configuration + + LaunchedEffect(activeChild) { + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 500), + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 500), + ) + } + } + } + + LaunchedEffect(bottomSheetState.value) { + if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) { + when (bottomSheetState.value) { + BottomSheetState.EXPANDED -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 100), + ) + } + BottomSheetState.COLLAPSED -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 100), + ) + } + } + } + } + + LaunchedEffect(primary, secondary) { + if (backgroundColor.isRunning) return@LaunchedEffect + + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.snapTo(secondary) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.snapTo(primary) + } + } + } + } + + @OptIn(ExperimentalDecomposeApi::class) + private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) { + stackNavigation.pushNew( + configuration = MarketsEntryChildFactory.Child.TokenDetails( + params = MarketsTokenDetailsComponent.Params( + token = token.toSerializable(), + appCurrency = appCurrency, + ), + ), + ) + } + + private fun onDetailsBack() { + stackNavigation.popWhile { it != MarketsEntryChildFactory.Child.TokenList } + } + + @AssistedFactory + interface Factory : MarketsEntryComponent.Factory { + override fun create(context: AppComponentContext): DefaultMarketsEntryComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt new file mode 100644 index 0000000000..0db378d0f5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt @@ -0,0 +1,52 @@ +package com.tangem.features.markets + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import kotlinx.serialization.Serializable +import javax.inject.Inject + +internal class MarketsEntryChildFactory @Inject constructor( + private val tokenListComponentFactory: MarketsTokenListComponent.Factory, + private val tokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, +) { + + @Serializable + @Immutable + sealed interface Child { + + @Serializable + @Immutable + data object TokenList : Child + + @Serializable + @Immutable + data class TokenDetails(val params: MarketsTokenDetailsComponent.Params) : Child + } + + fun createChild( + child: Child, + appComponentContext: AppComponentContext, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + onDetailsBack: () -> Unit, + ): Any { + return when (child) { + is Child.TokenDetails -> { + tokenDetailsComponentFactory.create( + context = appComponentContext, + params = child.params, + onBack = onDetailsBack, + ) + } + is Child.TokenList -> { + tokenListComponentFactory.create( + context = appComponentContext, + onTokenSelected = onTokenSelected, + ) + } + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt deleted file mode 100644 index c5329d71a3..0000000000 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.features.markets.component.impl - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.component.MarketsListComponent -import com.tangem.features.markets.model.MarketsListModel -import com.tangem.features.markets.ui.MarketsList -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultMarketsListComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, -) : MarketsListComponent, AppComponentContext by context { - - private val model: MarketsListModel = getOrCreateModel() - - @Composable - override fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) { - val state by model.state.collectAsStateWithLifecycle() - val bsState by bottomSheetState - - LaunchedEffect(bsState) { - model.containerBottomSheetState.value = bsState - } - - MarketsList( - modifier = modifier, - state = state, - onHeaderSizeChange = onHeaderSizeChange, - bottomSheetState = bsState, - ) - } - - @AssistedFactory - interface Factory : MarketsListComponent.Factory { - override fun create(context: AppComponentContext): DefaultMarketsListComponent - } -} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt new file mode 100644 index 0000000000..a706e5850e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt @@ -0,0 +1,32 @@ +package com.tangem.features.markets.details.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.markets.component.BottomSheetState +import kotlinx.serialization.Serializable + +@Stable +interface MarketsTokenDetailsComponent { + + @Serializable + data class Params( + val token: TokenMarketSerializable, + val appCurrency: AppCurrency, + ) + + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory { + fun create(context: AppComponentContext, params: Params, onBack: () -> Unit): MarketsTokenDetailsComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt new file mode 100644 index 0000000000..a1aa6b178c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt @@ -0,0 +1,40 @@ +package com.tangem.features.markets.details.api + +import com.tangem.domain.core.serialization.SerializedBigDecimal +import com.tangem.domain.markets.TokenMarket +import kotlinx.serialization.Serializable + +@Serializable +data class TokenMarketSerializable( + val id: String, + val name: String, + val symbol: String, + val marketCap: SerializedBigDecimal?, + val tokenQuotes: Quotes, + val imageUrl: String, +) { + + @Serializable + data class Quotes( + val currentPrice: SerializedBigDecimal, + val h24Percent: SerializedBigDecimal, + val weekPercent: SerializedBigDecimal, + val monthPercent: SerializedBigDecimal, + ) +} + +fun TokenMarket.toSerializable(): TokenMarketSerializable { + return TokenMarketSerializable( + id = id, + name = name, + symbol = symbol, + marketCap = marketCap, + tokenQuotes = TokenMarketSerializable.Quotes( + currentPrice = tokenQuotesShort.currentPrice, + h24Percent = tokenQuotesShort.h24ChangePercent, + weekPercent = tokenQuotesShort.weekChangePercent, + monthPercent = tokenQuotesShort.monthChangePercent, + ), + imageUrl = imageUrlLarge, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt new file mode 100644 index 0000000000..402356487a --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -0,0 +1,63 @@ +package com.tangem.features.markets.details.impl + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel +import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: MarketsTokenDetailsComponent.Params, + @Assisted private val onBack: () -> Unit, +) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent { + + private val model: MarketsTokenDetailsModel = getOrCreateModel(params) + + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + + val state by model.state.collectAsStateWithLifecycle() + val bsState by bottomSheetState + + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState + } + + MarketsTokenDetailsContent( + state = state, + onBackClick = onBack, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : MarketsTokenDetailsComponent.Factory { + override fun create( + context: AppComponentContext, + params: MarketsTokenDetailsComponent.Params, + onBack: () -> Unit, + ): DefaultMarketsTokenDetailsComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..eb07f7d3b3 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.details.impl.di + +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.impl.DefaultMarketsTokenDetailsComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindMarketsTokenDetailsComponent( + factory: DefaultMarketsTokenDetailsComponent.Factory, + ): MarketsTokenDetailsComponent.Factory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ModelModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ModelModule.kt new file mode 100644 index 0000000000..83e26ff055 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.details.impl.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(MarketsTokenDetailsModel::class) + fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt new file mode 100644 index 0000000000..2a40b0c83e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -0,0 +1,501 @@ +package com.tangem.features.markets.details.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.ui.charts.state.* +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.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.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter +import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter +import com.tangem.features.markets.details.impl.model.formatter.* +import com.tangem.features.markets.details.impl.model.formatter.formatAsPrice +import com.tangem.features.markets.details.impl.model.formatter.getChangePercentBetween +import com.tangem.features.markets.details.impl.model.formatter.getPercentByInterval +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R +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.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.joda.time.DateTime +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LargeClass", "LongParameterList") +@Stable +internal class MarketsTokenDetailsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenQuotesUseCase: GetTokenQuotesUseCase, + private val urlOpener: UrlOpener, +) : Model() { + + private var quotesJob = JobHolder() + private val params = paramsContainer.require() + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = params.appCurrency, + ) + + private val infoConverter = TokenMarketInfoConverter( + appCurrency = Provider { currentAppCurrency.value }, + onInfoClick = { + showInfoBottomSheet(it) + }, + onLinkClick = { + urlOpener.openUrl(it.url) + }, + ) + private val descriptionConverter = DescriptionConverter( + onReadModeClicked = { + showInfoBottomSheet(it) + }, + ) + + private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) { + chartData = MarketChartData.NoData.Loading + + updateLook { + val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() + + it.copy( + type = percentChangeType.toChartType(), + xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), + yAxisFormatter = { value -> + BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = value, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = "", + ) + }, + ) + } + } + + private val currentQuotes = MutableStateFlow( + TokenQuotes( + currentPrice = params.token.tokenQuotes.currentPrice, + h24ChangePercent = params.token.tokenQuotes.h24Percent, + weekChangePercent = params.token.tokenQuotes.weekPercent, + monthChangePercent = params.token.tokenQuotes.monthPercent, + m3ChangePercent = null, + m6ChangePercent = null, + yearChangePercent = null, + allTimeChangePercent = null, + ), + ) + + private var lastUpdatedTimestamp: Long = DateTime.now().millis + + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + val isVisibleOnScreen = MutableStateFlow(false) + + val state = MutableStateFlow( + MarketsTokenDetailsUM( + tokenName = params.token.name, + priceText = BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = params.token.tokenQuotes.currentPrice, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ), + dateTimeText = resourceReference(R.string.common_today), + priceChangePercentText = BigDecimalFormatter.formatPercent( + percent = params.token.tokenQuotes.h24Percent, + useAbsoluteValue = true, + ), + priceChangeType = if (params.token.tokenQuotes.h24Percent < BigDecimal.ZERO) { + PriceChangeType.DOWN + } else { + PriceChangeType.UP + }, + iconUrl = params.token.imageUrl, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = chartDataProducer, + chartLook = MarketChartLook(), + onLoadRetryClick = ::onLoadRetryClicked, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = ::onMarkerPointSelected, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = ::onSelectedIntervalChange, + markerSet = false, + body = MarketsTokenDetailsUM.Body.Loading, + triggerPriceChange = consumedEvent(), + infoBottomSheet = TangemBottomSheetConfig( + isShow = false, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + ), + ) + + private val loadChartJobHolder = JobHolder() + + init { + // reload screen if currency changed + modelScope.launch { + currentAppCurrency + .filter { it != params.appCurrency } + .collectLatest { + initialLoad() + } + } + + initialLoad() + } + + private fun initialLoad() { + loadInfo() + loadChart(state.value.selectedInterval) + modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS) + } + + private fun loadQuotes() { + modelScope.launch { + val result = getTokenQuotesUseCase( + tokenId = params.token.id, + appCurrency = currentAppCurrency.value, + ) + + result.onRight { res -> + updateQuotes(res) + } + } + } + + private fun loadChart(interval: PriceChangeInterval) { + modelScope.launch { + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + chartDataProducer.runTransactionSuspend { + chartData = MarketChartData.NoData.Loading + } + + val chart = getTokenPriceChartUseCase.invoke( + appCurrency = currentAppCurrency.value, + interval = interval, + tokenId = params.token.id, + ) + + state.update { + it.copy( + selectedInterval = interval, + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval) + + chart.onRight { + chartDataProducer.runTransactionSuspend { + chartData = MarketChartData.Data( + x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + y = it.priceY.toImmutableList(), + ) + + updateLook { + it.copy( + xAxisFormatter = xAxisFormatter, + type = state.value.priceChangeType.toChartType(), + ) + } + } + + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.DATA, + ), + ) + } + }.onLeft { + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.ERROR, + ), + body = if (it.body is MarketsTokenDetailsUM.Body.Error) { + MarketsTokenDetailsUM.Body.Nothing + } else { + it.body + }, + ) + } + } + }.saveIn(loadChartJobHolder) + } + + private fun loadInfo() { + state.update { + it.copy( + body = MarketsTokenDetailsUM.Body.Loading, + ) + } + + modelScope.launch { + val tokenMarketInfo = getTokenMarketInfoUseCase( + appCurrency = currentAppCurrency.value, + tokenId = params.token.id, + ) + + tokenMarketInfo.fold( + ifRight = { result -> + currentQuotes.value = result.quotes + val percent = result.quotes.getPercentByInterval(interval = state.value.selectedInterval) + state.update { + it.copy( + priceText = result.quotes.currentPrice.formatAsPrice(currentAppCurrency.value), + priceChangePercentText = result.quotes.getFormattedPercentByInterval( + interval = it.selectedInterval, + ), + priceChangeType = percent.percentChangeType(), + body = MarketsTokenDetailsUM.Body.Content( + description = descriptionConverter.convert(result), + infoBlocks = infoConverter.convert(result), + ), + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + }, + ifLeft = { + state.update { + if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { + it.copy( + body = MarketsTokenDetailsUM.Body.Error( + onLoadRetryClick = ::onLoadRetryClicked, + ), + ) + } else { + it.copy( + body = MarketsTokenDetailsUM.Body.Nothing, + ) + } + } + }, + ) + } + } + + private suspend fun updateQuotes(newQuotes: TokenQuotes) { + val triggerPriceChangeType = getFormattedPriceChange( + currentPrice = currentQuotes.value.currentPrice, + updatedPrice = newQuotes.currentPrice, + ) + val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) { + triggeredEvent( + data = triggerPriceChangeType, + onConsume = { + state.update { it.copy(triggerPriceChange = consumedEvent()) } + }, + ) + } else { + consumedEvent() + } + + val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval) + val priceChangeType = percent.percentChangeType() + + // wait until marker is removed + state.first { it.markerSet.not() } + + currentQuotes.value = newQuotes + lastUpdatedTimestamp = DateTime.now().millis + + state.update { stateToUpdate -> + stateToUpdate.copy( + priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency.value), + priceChangePercentText = newQuotes.getFormattedPercentByInterval( + interval = stateToUpdate.selectedInterval, + ), + priceChangeType = priceChangeType, + triggerPriceChange = trigger, + dateTimeText = getDefaultDateTimeString(stateToUpdate.selectedInterval), + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + } + + private fun onSelectedIntervalChange(interval: PriceChangeInterval) { + if (state.value.selectedInterval == interval) return + + val quotes = currentQuotes.value + val priceChangePercent = quotes.getFormattedPercentByInterval(interval) + + state.update { + it.copy( + priceChangePercentText = priceChangePercent, + selectedInterval = interval, + priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() + ?: PriceChangeType.NEUTRAL, + dateTimeText = getDefaultDateTimeString(interval), + ) + } + + loadChart(interval) + + if (priceChangePercent.isEmpty()) { + loadQuotes() + } + } + + @Suppress("MagicNumber") + private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) { + val currentState = state.value + + val dateTimeText = markerTimestamp?.let { + MarketsDateTimeFormatters.formatDateByIntervalWithMarker( + interval = currentState.selectedInterval, + markerTimestamp = it, + ) + } ?: getDefaultDateTimeString(currentState.selectedInterval) + + val priceText = (price ?: currentQuotes.value.currentPrice).formatAsPrice(currentAppCurrency.value) + + val percent = price?.let { + getChangePercentBetween( + previousPrice = it, + currentPrice = currentQuotes.value.currentPrice, + ) + } ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval) + + val percentText = percent?.let { + BigDecimalFormatter.formatPercent( + percent = it, + useAbsoluteValue = true, + ) + } ?: "" + + state.update { stateToUpdate -> + stateToUpdate.copy( + markerSet = markerTimestamp != null, + dateTimeText = dateTimeText, + priceText = priceText, + priceChangePercentText = percentText, + priceChangeType = percent.percentChangeType(), + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + } + + private fun showInfoBottomSheet(content: InfoBottomSheetContent) { + state.update { stateToUpdate -> + stateToUpdate.copy( + infoBottomSheet = stateToUpdate.infoBottomSheet.copy( + isShow = true, + onDismissRequest = ::hideInfoBottomSheet, + content = content, + ), + ) + } + } + + private fun hideInfoBottomSheet() { + state.update { stateToUpdate -> + stateToUpdate.copy( + infoBottomSheet = stateToUpdate.infoBottomSheet.copy( + isShow = false, + ), + ) + } + } + + private fun onLoadRetryClicked() { + val currentState = state.value + + if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.ERROR) { + loadChart(currentState.selectedInterval) + } + + if (currentState.body is MarketsTokenDetailsUM.Body.Error || + currentState.body is MarketsTokenDetailsUM.Body.Nothing + ) { + loadInfo() + modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS) + } + } + + private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { + launch { + while (true) { + delay(timeMillis) + // Update quotes only when the container bottom sheet is in the expanded state + containerBottomSheetState.first { it == BottomSheetState.EXPANDED } + // and is visible on the screen + isVisibleOnScreen.first { it } + + loadQuotes() + } + }.saveIn(quotesJob) + } + + private fun getDefaultDateTimeString(interval: PriceChangeInterval): TextReference { + return MarketsDateTimeFormatters.formatDateByInterval( + interval = interval, + startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval( + interval = interval, + currentTimestamp = lastUpdatedTimestamp, + ), + ) + } + + private companion object { + const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt new file mode 100644 index 0000000000..40d76daa99 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt @@ -0,0 +1,41 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.converter.Converter + +@Stable +internal class DescriptionConverter( + val onReadModeClicked: (InfoBottomSheetContent) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? { + return value.shortDescription?.let { desc -> + MarketsTokenDetailsUM.Description( + shortDescription = stringReference(desc), + fullDescription = value.fullDescription?.let { fullDescription -> + stringReference(fullDescription) + }, + onReadMoreClick = { + onReadModeClicked( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_about_token_title, + wrappedList( + value.name, + ), + ), + body = stringReference(value.fullDescription ?: ""), + ), + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt new file mode 100644 index 0000000000..9bbcff51d9 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt @@ -0,0 +1,153 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.InsightsUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Stable +internal class InsightsConverter( + private val appCurrency: Provider, + private val onInfoClick: (InfoBottomSheetContent) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo.Insights): InsightsUM { + return with(value) { + InsightsUM( + h24Info = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.day, + holdersChange = holdersChange?.day, + liquidityChange = liquidityChange?.day, + buyPressureChange = buyPressureChange?.day, + ), + weekInfo = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.week, + holdersChange = holdersChange?.week, + liquidityChange = liquidityChange?.week, + buyPressureChange = buyPressureChange?.week, + ), + monthInfo = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.month, + holdersChange = holdersChange?.month, + liquidityChange = liquidityChange?.month, + buyPressureChange = buyPressureChange?.month, + ), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_insights), + body = stringReference("//TODO"), + ), + ) + }, + ) + } + } + + private fun createInfoPointList( + experiencedBuyerChange: BigDecimal?, + holdersChange: BigDecimal?, + liquidityChange: BigDecimal?, + buyPressureChange: BigDecimal?, + ): ImmutableList { + return persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = experiencedBuyerChange.convertChange(), + change = experiencedBuyerChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + body = resourceReference(R.string.markets_token_details_experienced_buyers_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = buyPressureChange.convertChange(isFiatValue = true), + change = buyPressureChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_buy_pressure), + body = resourceReference(R.string.markets_token_details_buy_pressure_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = holdersChange.convertChange(), + change = holdersChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_holders), + body = resourceReference(R.string.markets_token_details_holders_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = liquidityChange.convertChange(), + change = liquidityChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_liquidity), + body = resourceReference(R.string.markets_token_details_liquidity_description), + ), + ) + }, + ), + ) + } + + private fun BigDecimal?.changeType(): InfoPointUM.ChangeType? { + return when { + this == null -> null + this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP + this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN + else -> null + } + } + + private fun BigDecimal?.convertChange(isFiatValue: Boolean = false): String { + if (this == null) return StringsSigns.DASH_SIGN + + val value = if (isFiatValue) { + val currency = appCurrency() + BigDecimalFormatter.formatCompactFiatAmount( + amount = this.abs(), + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ) + } else { + BigDecimalFormatter.formatCompactAmount(amount = this.abs()) + } + + val spacing = if (isFiatValue) " " else "" + + return when { + this > BigDecimal.ZERO -> StringsSigns.PLUS + spacing + value + this < BigDecimal.ZERO -> StringsSigns.MINUS + spacing + value + this == BigDecimal.ZERO -> value + else -> StringsSigns.DASH_SIGN + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt new file mode 100644 index 0000000000..1b571e168f --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt @@ -0,0 +1,49 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.LinksUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +@Stable +internal class LinksConverter( + private val onLinkClick: (LinksUM.Link) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo.Links): LinksUM { + return LinksUM( + officialLinks = value.officialLinks?.map { it.convert() }.orEmpty().toImmutableList(), + social = value.social?.map { it.convert() }.orEmpty().toImmutableList(), + repository = value.repository?.map { it.convert() }.orEmpty().toImmutableList(), + blockchainSite = value.blockchainSite?.map { it.convert() }.orEmpty().toImmutableList(), + onLinkClick = onLinkClick, + ) + } + + private fun TokenMarketInfo.Link.convert(): LinksUM.Link { + return LinksUM.Link( + title = stringReference(title), + iconRes = getIconById(id), + url = link, + ) + } + + private fun getIconById(id: String?): Int { + return when (id) { + "linkedin" -> R.drawable.ic_linkedin_24 + "discord" -> R.drawable.ic_discord_24 + "youtube" -> R.drawable.ic_youtube_24 + "telegram" -> R.drawable.ic_telegram_24 + "github" -> R.drawable.ic_github_24 + "twitter" -> R.drawable.ic_twitter_24 + "facebook" -> R.drawable.ic_facebook_24 + "reddit" -> R.drawable.ic_reddit_24 + "instagram" -> R.drawable.ic_instagram_24 + "whitepaper" -> R.drawable.ic_doc_24 + else -> R.drawable.ic_arrow_top_right_24 + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt new file mode 100644 index 0000000000..4bf9f1e111 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt @@ -0,0 +1,135 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.MetricsUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +@Stable +internal class MetricsConverter( + private val appCurrency: Provider, + private val onInfoClick: (InfoBottomSheetContent) -> Unit, +) : Converter { + + @Suppress("LongMethod") + override fun convert(value: TokenMarketInfo.Metrics): MetricsUM { + return with(value) { + MetricsUM( + metrics = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_capitalization), + value = marketCap.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_market_capitalization), + body = resourceReference( + R.string.markets_token_details_market_capitalization_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_rating), + value = marketRating?.toString() ?: StringsSigns.DASH_SIGN, + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_market_rating), + body = resourceReference(R.string.markets_token_details_market_rating_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_trading_volume), + value = volume24h.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_trading_volume), + body = resourceReference( + R.string.markets_token_details_trading_volume_24h_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + value = fullyDilutedValuation.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + body = resourceReference( + R.string.markets_token_details_fully_diluted_valuation_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_circulating_supply), + value = circulatingSupply.formatAmount(crypto = true), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_circulating_supply), + body = resourceReference( + R.string.markets_token_details_circulating_supply_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_total_supply), + value = totalSupply.formatAmount(crypto = true), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_total_supply), + body = resourceReference(R.string.markets_token_details_total_supply_description), + ), + ) + }, + ), + ), + ) + } + } + + private fun BigDecimal?.formatAmount(crypto: Boolean = false): String { + return if (crypto) { + val formatter = NumberFormat.getNumberInstance(Locale.getDefault()).apply { + maximumFractionDigits = 0 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + formatter.format(this) + } else { + val currency = appCurrency() + BigDecimalFormatter.formatFiatAmount( + fiatAmount = this, + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + decimals = 0, + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt new file mode 100644 index 0000000000..0ccf01cde7 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt @@ -0,0 +1,59 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import java.math.BigDecimal +import java.math.RoundingMode + +@Stable +internal class PricePerformanceConverter( + private val appCurrency: Provider, +) : Converter { + + override fun convert(value: TokenMarketInfo.PricePerformance): PricePerformanceUM { + return PricePerformanceUM( + h24 = value.day.convert(), + month = value.month.convert(), + all = value.allTime.convert(), + ) + } + + private fun TokenMarketInfo.Range?.convert(): PricePerformanceUM.Value { + if (this == null) { + return PricePerformanceUM.Value( + low = StringsSigns.DASH_SIGN, + high = StringsSigns.DASH_SIGN, + indicatorFraction = 0f, + ) + } + + return PricePerformanceUM.Value( + low = low.convert(), + high = high.convert(), + indicatorFraction = calculateFraction(), + ) + } + + private fun BigDecimal?.convert(): String { + val currency = appCurrency() + + return BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = this, + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ) + } + + private fun TokenMarketInfo.Range.calculateFraction(): Float { + if (low == null || high == null || low == BigDecimal.ZERO) return 0f + return (high!! - low!!).divide(low!!, RoundingMode.HALF_UP) + .setScale(2, RoundingMode.HALF_UP) + .toFloat().coerceAtMost(1f) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt new file mode 100644 index 0000000000..302aeb7258 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt @@ -0,0 +1,35 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.converter.Converter + +// TODO implement when backend is ready +@Stable +internal class SecurityScoreConverter( + private val onInfoClick: (InfoBottomSheetContent) -> Unit, +) : Converter { + + override fun convert(value: Unit): SecurityScoreUM { + return with(value) { + SecurityScoreUM( + score = 4.7f, + description = "Based on 3 ratings", + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_security_score), + body = stringReference("markets_token_details_security_score_description"), + // FIXME + // resourceReference(R.string.markets_token_details_security_score_description) + ), + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt new file mode 100644 index 0000000000..f03cbff9c4 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt @@ -0,0 +1,34 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.LinksUM +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter + +@Stable +internal class TokenMarketInfoConverter( + appCurrency: Provider, + onInfoClick: (InfoBottomSheetContent) -> Unit, + onLinkClick: (LinksUM.Link) -> Unit, +) : Converter { + + private val insightsConverter = InsightsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick) + private val securityScoreConverter = SecurityScoreConverter(onInfoClick = onInfoClick) + private val metricsConverter = MetricsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick) + private val pricePerformanceConverter = PricePerformanceConverter(appCurrency = appCurrency) + private val linksConverter = LinksConverter(onLinkClick = onLinkClick) + + override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks { + return MarketsTokenDetailsUM.InformationBlocks( + insights = value.insights?.let { insightsConverter.convert(it) }, + securityScore = securityScoreConverter.convert(Unit), + metrics = value.metrics?.let { metricsConverter.convert(it) }, + pricePerformance = value.pricePerformance?.let { pricePerformanceConverter.convert(it) }, + links = value.links?.let { linksConverter.convert(it) }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt new file mode 100644 index 0000000000..f164289fe6 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt @@ -0,0 +1,99 @@ +package com.tangem.features.markets.details.impl.model.formatter + +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenQuotes +import java.math.BigDecimal +import java.math.RoundingMode + +internal fun BigDecimal.formatAsPrice(currency: AppCurrency): String { + return BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = this, + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ) +} + +internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInterval): String { + val percent = when (interval) { + PriceChangeInterval.H24 -> h24ChangePercent + PriceChangeInterval.WEEK -> weekChangePercent + PriceChangeInterval.MONTH -> monthChangePercent + PriceChangeInterval.MONTH3 -> m3ChangePercent + PriceChangeInterval.MONTH6 -> m6ChangePercent + PriceChangeInterval.YEAR -> yearChangePercent + PriceChangeInterval.ALL_TIME -> allTimeChangePercent + } + + return percent?.let { + BigDecimalFormatter.formatPercent( + percent = it, + useAbsoluteValue = true, + ) + } ?: "" +} + +internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? { + return when (interval) { + PriceChangeInterval.H24 -> h24ChangePercent + PriceChangeInterval.WEEK -> weekChangePercent + PriceChangeInterval.MONTH -> monthChangePercent + PriceChangeInterval.MONTH3 -> m3ChangePercent + PriceChangeInterval.MONTH6 -> m6ChangePercent + PriceChangeInterval.YEAR -> yearChangePercent + PriceChangeInterval.ALL_TIME -> allTimeChangePercent + } +} + +internal fun BigDecimal?.percentChangeType(): PriceChangeType { + return when { + this == null -> PriceChangeType.NEUTRAL + this > BigDecimal.ZERO -> PriceChangeType.UP + this < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } +} + +@Suppress("MagicNumber") +internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: BigDecimal): BigDecimal { + return if (previousPrice == BigDecimal.ZERO) { + BigDecimal.ZERO + } else { + currentPrice.subtract(previousPrice).divide(previousPrice, 4, RoundingMode.HALF_UP) + } +} + +internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType { + val updatedPriceDecimals = BigDecimalFormatter.getProperFiatPriceDecimals(updatedPrice) + + val current = currentPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP) + val updated = updatedPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP) + + return when { + updated > current -> PriceChangeType.UP + updated < current -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } +} + +internal fun PriceChangeType.toChartType(): MarketChartLook.Type { + return when (this) { + PriceChangeType.UP -> MarketChartLook.Type.Growing + PriceChangeType.DOWN -> MarketChartLook.Type.Falling + PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral + } +} + +@Suppress("MagicNumber") +internal fun getChartTypeByPercent(percent: BigDecimal?): MarketChartLook.Type { + val scaled = percent?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> return MarketChartLook.Type.Neutral + scaled > BigDecimal.ZERO -> MarketChartLook.Type.Growing + scaled < BigDecimal.ZERO -> MarketChartLook.Type.Falling + else -> MarketChartLook.Type.Neutral + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt new file mode 100644 index 0000000000..acc0ec44fa --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt @@ -0,0 +1,130 @@ +package com.tangem.features.markets.details.impl.model.formatter + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.formatAsDateTime +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.impl.R +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import java.math.BigDecimal + +internal object MarketsDateTimeFormatters { + + private const val H24_MILLIS = 24L * 60 * 60 * 1000 + private const val WEEK_MILLIS = 7L * H24_MILLIS + + private val dateTimeMMMFormatter by lazy { + DateTimeFormatters.getBestFormatterBySkeleton("dd MMM Hm") + } + + private val dateFormatter = DateTimeFormatters.dateDDMMYYYY + + internal fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String { + return when (interval) { + PriceChangeInterval.H24 -> { value: BigDecimal -> + value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter) + } + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd) + } + PriceChangeInterval.YEAR -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd) + } + PriceChangeInterval.ALL_TIME -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateYYYY) + } + } + } + + internal fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference { + return when (interval) { + PriceChangeInterval.H24 -> resourceReference(R.string.common_today) + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + startTimestamp.formatAsDateTime(MarketsDateTimeFormatters.dateTimeMMMFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + startTimestamp.formatAsDateTime(MarketsDateTimeFormatters.dateFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.ALL_TIME -> resourceReference(R.string.common_all) + } + } + + internal fun formatDateByIntervalWithMarker( + interval: PriceChangeInterval, + markerTimestamp: BigDecimal, + ): TextReference { + return when (interval) { + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + markerTimestamp.toLong().formatAsDateTime(MarketsDateTimeFormatters.dateTimeMMMFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + PriceChangeInterval.ALL_TIME, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + markerTimestamp.toLong().formatAsDateTime(MarketsDateTimeFormatters.dateFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + } + } + + @Suppress("MagicNumber") + fun getStartTimestampByInterval(interval: PriceChangeInterval, currentTimestamp: Long): Long { + return when (interval) { + PriceChangeInterval.H24 -> currentTimestamp - H24_MILLIS + PriceChangeInterval.WEEK -> currentTimestamp - WEEK_MILLIS + PriceChangeInterval.MONTH -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(1).millis + PriceChangeInterval.MONTH3 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(3).millis + PriceChangeInterval.MONTH6 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(6).millis + PriceChangeInterval.YEAR -> DateTime(currentTimestamp, DateTimeZone.UTC).minusYears(1).millis + PriceChangeInterval.ALL_TIME -> 0 + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt new file mode 100644 index 0000000000..ecfc715abd --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -0,0 +1,293 @@ +package com.tangem.features.markets.details.impl.ui + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.EventEffect +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.disableNestedScroll +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.details.impl.ui.components.InfoBottomSheet +import com.tangem.features.markets.details.impl.ui.components.MarketTokenDetailsChart +import com.tangem.features.markets.details.impl.ui.components.tokenMarketDetailsBody +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Suppress("UnusedPrivateMember") +@Composable +internal fun MarketsTokenDetailsContent( + state: MarketsTokenDetailsUM, + onBackClick: () -> Unit, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + Content( + modifier = modifier, + state = state, + onBackClick = onBackClick, + onHeaderSizeChange = onHeaderSizeChange, + ) + + InfoBottomSheet(config = state.infoBottomSheet) +} + +@Suppress("UnusedPrivateMember") +@Composable +private fun Content( + state: MarketsTokenDetailsUM, + onBackClick: () -> Unit, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = LocalMainBottomSheetColor.current.value + val density = LocalDensity.current + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + + Column( + modifier = modifier + .drawBehind { drawRect(backgroundColor) } + .fillMaxSize(), + ) { + TangemTopAppBar( + modifier = Modifier.onGloballyPositioned { + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } + } + }, + title = state.tokenName, + startButton = TopAppBarButtonUM.Back(onBackClick), + ) + + SpacerH4() + + LazyColumn( + modifier = Modifier.disableNestedScroll(), + contentPadding = PaddingValues(bottom = bottomBarHeight), + ) { + item("header") { + Header( + state = state, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH16() } + item("intervalSelector") { + IntervalSelector( + trendInterval = state.selectedInterval, + onIntervalClick = state.onSelectedIntervalChange, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH32() } + item( + contentType = "chart", + ) { + MarketTokenDetailsChart( + modifier = Modifier.fillMaxWidth(), + state = state.chartState, + ) + } + item { SpacerH16() } + + tokenMarketDetailsBody( + state = state.body, + ) + } + } +} + +@Composable +private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + Modifier.weight(1f), + ) { + TokenPriceText( + price = state.priceText, + triggerPriceChange = state.triggerPriceChange, + ) + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) { + Text( + text = state.dateTimeText.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + PriceChangeInPercent( + valueInPercent = state.priceChangePercentText, + type = state.priceChangeType, + textStyle = TangemTheme.typography.caption2, + ) + } + } + SpacerW4() + CoinIcon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size48), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + } +} + +@Composable +private fun TokenPriceText( + price: String, + triggerPriceChange: StateEvent, + modifier: Modifier = Modifier, +) { + val growColor = TangemTheme.colors.text.accent + val fallColor = TangemTheme.colors.text.warning + val generalColor = TangemTheme.colors.text.primary1 + + val color = remember { Animatable(generalColor) } + + EventEffect(triggerPriceChange) { + val nextColor = when (it) { + PriceChangeType.UP, + -> growColor + PriceChangeType.DOWN -> fallColor + PriceChangeType.NEUTRAL -> return@EventEffect + } + + color.animateTo(nextColor, snap()) + color.animateTo(generalColor, tween(durationMillis = 500)) + } + + Text( + modifier = modifier, + text = price, + color = color.value, + style = TangemTheme.typography.head, + ) +} + +@Composable +private fun IntervalSelector( + trendInterval: PriceChangeInterval, + onIntervalClick: (PriceChangeInterval) -> Unit, + modifier: Modifier = Modifier, +) { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + PriceChangeInterval.ALL_TIME, + ), + color = TangemTheme.colors.button.secondary, + initialSelectedItem = trendInterval, + onClick = onIntervalClick, + modifier = modifier, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding( + vertical = TangemTheme.dimens.spacing4, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +@Composable +fun PriceChangeInterval.getText(): TextReference { + return when (this) { + PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title) + PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title) + PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title) + PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title) + PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title) + PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title) + PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title) + } +} + +@Preview +@Composable +private fun Preview() { + TangemThemePreview { + Content( + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + state = MarketsTokenDetailsUM( + tokenName = "Token Name", + priceText = "$0.00000000324", + dateTimeText = stringReference("Today"), + priceChangePercentText = "52.00%", + iconUrl = "", + priceChangeType = PriceChangeType.UP, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = MarketChartDataProducer.build { }, + chartLook = MarketChartLook(), + onLoadRetryClick = {}, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = { _, _ -> }, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = { }, + body = MarketsTokenDetailsUM.Body.Loading, + infoBottomSheet = TangemBottomSheetConfig( + isShow = false, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + markerSet = false, + triggerPriceChange = consumedEvent(), + ), + onHeaderSizeChange = {}, + onBackClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt new file mode 100644 index 0000000000..e895b421f5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt @@ -0,0 +1,112 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.impl.R + +@Composable +internal fun Description( + description: TextReference, + hasFullDescription: Boolean, + onReadMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (hasFullDescription) { + val text = buildAnnotatedString { + withStyle(SpanStyle(color = TangemTheme.colors.text.secondary)) { + append(description.resolveReference()) + } + withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { + append(" " + stringResource(R.string.common_read_more)) + } + } + + ClickableText( + modifier = modifier, + text = text, + style = TangemTheme.typography.body2, + ) { + text.spanStyles.getOrNull(1)?.let { spanStyle -> + if (it in spanStyle.start..spanStyle.end) { + onReadMoreClick() + } + } + } + } else { + Text( + modifier = modifier, + text = description.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +internal fun DescriptionPlaceholder(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.8f), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } +} + +@Preview +@Composable +private fun ContentPreview() { + TangemThemePreview { + Description( + description = stringReference( + "XRP (XRP) is a cryptocurrency launched in January 2009, where the first " + + "genesis block was mined on 9th January 2009", + ), + hasFullDescription = true, + onReadMoreClick = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { + ContentPreview() + }, + shimmerContent = { + DescriptionPlaceholder() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt new file mode 100644 index 0000000000..0d44a41f91 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt @@ -0,0 +1,47 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent + +@Composable +internal fun InfoBottomSheet(config: TangemBottomSheetConfig) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + TangemBottomSheet( + config = config, + skipPartiallyExpanded = false, + addBottomInsets = false, + title = { + TangemBottomSheetTitle(title = it.title) + }, + content = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = TangemTheme.dimens.spacing28), + ) { + Text( + text = it.body.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + SpacerH(bottomBarHeight) + } + }, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoPoint.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoPoint.kt new file mode 100644 index 0000000000..3ca286968f --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoPoint.kt @@ -0,0 +1,186 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM + +@Composable +internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + horizontalAlignment = Alignment.Start, + ) { + if (infoPointUM.onInfoClick != null) { + TooltipText( + text = infoPointUM.title, + onInfoClick = infoPointUM.onInfoClick, + textStyle = TangemTheme.typography.caption2, + ) + } else { + Text( + text = infoPointUM.title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row { + Text( + text = infoPointUM.value, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (infoPointUM.change != null) { + SpacerW4() + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size8) + .align(Alignment.CenterVertically), + imageVector = ImageVector.vectorResource( + id = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> R.drawable.ic_arrow_up_8 + InfoPointUM.ChangeType.DOWN -> R.drawable.ic_arrow_down_8 + }, + ), + tint = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> TangemTheme.colors.icon.accent + InfoPointUM.ChangeType.DOWN -> TangemTheme.colors.icon.warning + }, + contentDescription = null, + ) + } + } + } +} + +@Composable +internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + horizontalAlignment = Alignment.Start, + ) { + if (withTooltip) { + Box( + modifier = Modifier + .requiredHeight(TangemTheme.dimens.size16) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.caption2, + textSizeHeight = false, + ) + } + } else { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.caption2, + textSizeHeight = true, + ) + } + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + style = TangemTheme.typography.body1, + textSizeHeight = true, + ) + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + Column( + modifier = Modifier + .width(150.dp) + .background(TangemTheme.colors.background.tertiary), + ) { + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.UP, + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.DOWN, + onInfoClick = { }, + ), + ) + } + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewShimmer() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + Column( + modifier = Modifier + .width(150.dp) + .background(TangemTheme.colors.background.tertiary), + ) { + InfoPointShimmer(modifier = Modifier.fillMaxWidth()) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + } + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt new file mode 100644 index 0000000000..df5dab7cce --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt @@ -0,0 +1,195 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.block.information.GridItems +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.InsightsUM +import com.tangem.features.markets.details.impl.ui.getText +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { + var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) } + + InformationBlock( + modifier = modifier, + title = { + TooltipText( + text = resourceReference(R.string.markets_token_details_insights), + textStyle = TangemTheme.typography.subtitle2, + onInfoClick = state.onInfoClick, + ) + }, + action = { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + ), + initialSelectedItem = PriceChangeInterval.H24, + onClick = { currentInterval = it }, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding(vertical = TangemTheme.dimens.spacing4), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } + }, + content = { + val infoPoints = when (currentInterval) { + PriceChangeInterval.H24 -> state.h24Info + PriceChangeInterval.WEEK -> state.weekInfo + PriceChangeInterval.MONTH -> state.monthInfo + else -> state.h24Info + } + + GridItems( + items = infoPoints, + itemContent = { + InfoPoint( + modifier = Modifier.align(Alignment.CenterStart), + infoPointUM = it, + ) + }, + ) + }, + ) +} + +@Composable +internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) { + val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() } + val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() } + val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4 + + InformationBlock( + modifier = modifier, + title = { + RectangleShimmer( + modifier = Modifier + .height(headerHeight) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + }, + content = { + GridItems( + items = List(size = 4) { it }.toImmutableList(), + horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + itemContent = { + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + ) + }, + ) + }, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + InsightsBlock( + state = InsightsUM( + h24Info = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000 000", + ), + ), + weekInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000", + ), + ), + monthInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000", + ), + ), + onInfoClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { ContentPreview() }, + shimmerContent = { InsightsBlockPlaceholder() }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt new file mode 100644 index 0000000000..ca7e4d4390 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt @@ -0,0 +1,221 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.LinksUM +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResource(id = R.string.markets_token_details_links), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + Column { + SubBlock( + title = stringResource(id = R.string.markets_token_details_official_links), + links = state.officialLinks, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResource(id = R.string.markets_token_details_social), + links = state.social, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResource(id = R.string.markets_token_details_repository), + links = state.repository, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResource(id = R.string.markets_token_details_blockchain_site), + links = state.blockchainSite, + onLinkClick = state.onLinkClick, + ) + } + }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SubBlock( + links: ImmutableList, + onLinkClick: (LinksUM.Link) -> Unit, + modifier: Modifier = Modifier, + lastBlock: Boolean = false, + title: String = "Official links", +) { + if (links.isEmpty()) return + + DividerContainer( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + text = title, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + links.fastForEach { + SecondarySmallButton( + config = SmallButtonConfig( + text = it.title, + onClick = { onLinkClick(it) }, + icon = TangemButtonIconPosition.Start(iconResId = it.iconRes), + ), + ) + } + } + } + } +} + +@Composable +fun LinksBlockPlaceholder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.subtitle2, + ) + }, + content = { + Column { + SubBlockPlaceholder() + SubBlockPlaceholder() + SubBlockPlaceholder(lastBlock = true) + } + }, + ) +} + +@Composable +private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) { + DividerContainer( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + TextShimmer( + modifier = Modifier.width(78.dp), + style = TangemTheme.typography.caption2, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + repeat(times = 3) { + SmallButtonShimmer( + modifier = Modifier.weight(1f), + withIcon = true, + ) + } + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + LinksBlock( + state = LinksUM( + officialLinks = persistentListOf( + LinksUM.Link( + title = stringReference("Website"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = stringReference("Website"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = stringReference("Website"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + social = persistentListOf( + LinksUM.Link( + title = stringReference("Twitter"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = stringReference("Facebook"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + repository = persistentListOf( + LinksUM.Link( + title = stringReference("Github"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + blockchainSite = persistentListOf(), + onLinkClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreview() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { LinksBlockPlaceholder() }, + actualContent = { ContentPreview() }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt new file mode 100644 index 0000000000..83bc41555e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -0,0 +1,81 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import com.tangem.common.ui.charts.MarketChart +import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.common.ui.charts.state.rememberMarketChartState +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData + +@Composable +internal fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, modifier: Modifier = Modifier) { + val growingColor = TangemTheme.colors.icon.accent + val fallingColor = TangemTheme.colors.icon.warning + val neutralColor = TangemTheme.colors.icon.informative + + val chartState = rememberMarketChartState( + dataProducer = state.dataProducer, + colorMapper = { + when (it) { + MarketChartLook.Type.Growing -> growingColor + MarketChartLook.Type.Falling -> fallingColor + MarketChartLook.Type.Neutral -> neutralColor + } + }, + onMarkerShown = state.onMarkerPointSelected, + ) + + val backgroundColor = LocalMainBottomSheetColor.current.value + val bottomChartAxisHeight = getMarketChartBottomAxisHeight() + + Box(modifier) { + MarketChart( + modifier = Modifier.fillMaxWidth(), + state = chartState, + ) + + if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) { + Box( + Modifier + .drawBehind { drawRect(backgroundColor) } + .matchParentSize() + .padding(bottom = bottomChartAxisHeight), + ) { + when (state.status) { + MarketsTokenDetailsUM.ChartState.Status.LOADING -> { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size16) + .align(Alignment.Center), + color = TangemTheme.colors.text.accent, + strokeWidth = TangemTheme.dimens.size2, + ) + } + MarketsTokenDetailsUM.ChartState.Status.ERROR -> { + UnableToLoadData( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .align(Alignment.Center), + onRetryClick = state.onLoadRetryClick, + ) + } + else -> {} + } + } + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt new file mode 100644 index 0000000000..4825c2ab32 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt @@ -0,0 +1,166 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.TextButton +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.GridItems +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.MetricsUM +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +const val MAX_METRICS_COUNT = 6 + +@Composable +internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { + var expanded by remember { mutableStateOf(false) } + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResource(id = R.string.markets_token_details_metrics), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + action = { + if (state.metrics.size > MAX_METRICS_COUNT) { + ShowLessMoreButton(expanded = expanded, onClick = { expanded = !expanded }) + } + }, + content = { + val metrics = if (expanded) { + state.metrics + } else { + state.metrics.take(MAX_METRICS_COUNT).toImmutableList() + } + + GridItems( + items = metrics, + itemContent = { + InfoPoint(infoPointUM = it) + }, + ) + }, + ) +} + +// TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock +@Composable +private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) { + // FIXME add string resources + val text = if (expanded) { + "See less" + } else { + "See more" + } + + TextButton( + text = text, + onClick = onClick, + colors = TangemButtonsDefaults.positiveButtonColors, + textStyle = TangemTheme.typography.body2, + ) +} + +@Composable +internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + style = TangemTheme.typography.subtitle2, + ) + }, + action = { + Box(Modifier) + }, + content = { + GridItems( + items = List(size = 6) { it }.toImmutableList(), + horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + itemContent = { + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + }, + ) + }, + ) +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun BlockPreview() { + TangemThemePreview { + MetricsBlock( + state = MetricsUM( + metrics = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_capitalization), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_rating), + value = "A", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_trading_volume), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_circulating_supply), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_total_supply), + value = "1.2T", + onInfoClick = {}, + ), + ), + ), + ) + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { BlockPreview() }, + shimmerContent = { MetricsBlockPlaceholder() }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt new file mode 100644 index 0000000000..66a09fb533 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt @@ -0,0 +1,247 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +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.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemAnimations +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM +import com.tangem.features.markets.details.impl.ui.getText +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier = Modifier) { + var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) } + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResource(id = R.string.markets_token_details_price_performance), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + action = { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.MONTH, + PriceChangeInterval.ALL_TIME, + ), + initialSelectedItem = PriceChangeInterval.H24, + onClick = { currentInterval = it }, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding(vertical = TangemTheme.dimens.spacing4), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } + }, + content = { + val value = when (currentInterval) { + PriceChangeInterval.H24 -> state.h24 + PriceChangeInterval.MONTH -> state.month + PriceChangeInterval.ALL_TIME -> state.all + else -> error("") + } + + Content( + modifier = Modifier.fillMaxWidth(), + state = value, + ) + }, + ) +} + +@Composable +private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifier) { + val animatedIndicatorFraction by TangemAnimations.horizontalIndicatorAsState( + targetFraction = state.indicatorFraction, + ) + + Column( + modifier = modifier + .padding(vertical = TangemTheme.dimens.spacing8), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.markets_token_details_low), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerW8() + Text( + text = stringResource(R.string.markets_token_details_high), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + LinearProgressIndicator( + modifier = Modifier + .height(TangemTheme.dimens.size6) + .fillMaxWidth(), + progress = { animatedIndicatorFraction }, + color = TangemTheme.colors.text.accent, + trackColor = TangemTheme.colors.background.tertiary, + strokeCap = StrokeCap.Round, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = state.low, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerW8() + Text( + text = state.high, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.End, + ) + } + } +} + +@Composable +internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) { + val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() } + val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() } + val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4 + + InformationBlock( + modifier = modifier, + title = { + RectangleShimmer( + modifier = Modifier + .height(headerHeight) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + }, + content = { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextShimmer( + modifier = Modifier.width(35.dp), + style = TangemTheme.typography.caption2, + ) + SpacerW8() + TextShimmer( + modifier = Modifier.width(35.dp), + style = TangemTheme.typography.caption2, + ) + } + RectangleShimmer( + modifier = Modifier + .height(TangemTheme.dimens.size6) + .fillMaxWidth(), + radius = 27.dp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextShimmer( + modifier = Modifier.width(TangemTheme.dimens.size56), + style = TangemTheme.typography.body1, + ) + SpacerW8() + TextShimmer( + modifier = Modifier.width(TangemTheme.dimens.size56), + style = TangemTheme.typography.body1, + ) + } + } + }, + ) +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + PricePerformanceBlock( + modifier = Modifier, + state = PricePerformanceUM( + h24 = PricePerformanceUM.Value( + low = "\$38,5K", + high = "\$58,5K", + indicatorFraction = 0.5f, + ), + month = PricePerformanceUM.Value( + low = "\$500,5K", + high = "\$5800,5K", + indicatorFraction = 0.8f, + ), + all = PricePerformanceUM.Value( + low = "\$58,52", + high = "\$580,5M", + indicatorFraction = 0.2f, + ), + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreview() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + PricePerformanceBlockPlaceholder() + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBlock.kt new file mode 100644 index 0000000000..66a75660ef --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBlock.kt @@ -0,0 +1,203 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.annotation.FloatRange +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM +import com.tangem.features.markets.impl.R +import kotlin.math.round + +private const val STARS_COUNT = 5 + +@Composable +internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) { + val rounded = state.score.roundTo1decimal() + val percentage = rounded / STARS_COUNT + InformationBlock( + modifier = modifier, + title = { + Column( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + TooltipText( + text = resourceReference(R.string.markets_token_details_security_score), + onInfoClick = state.onInfoClick, + textStyle = TangemTheme.typography.subtitle2, + ) + + Text( + text = state.description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + action = { + Row( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = rounded.toString(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Stars(fraction = percentage) + } + }, + ) +} + +@Suppress("MagicNumber") +@Composable +private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) { + val grayColor = TangemTheme.colors.icon.inactive + + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(times = 5) { i -> + Box( + modifier = Modifier.size(TangemTheme.dimens.size16), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(13.dp) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithCache { + onDrawWithContent { + val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0) + val starFractionFloat = starFraction + .toFloat() + .roundTo1decimal() + + drawContent() + drawRect( + color = grayColor, + topLeft = Offset(x = size.width * starFractionFloat, y = 0f), + size = Size(size.width * (1 - starFractionFloat), size.height), + blendMode = BlendMode.SrcIn, + ) + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_star_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + } + } +} + +@Suppress("MagicNumber") +private fun Float.roundTo1decimal(): Float { + return round(this * 10) / 10 +} + +@Composable +internal fun SecurityScorePlaceHolder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + Column( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.subtitle2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } + }, + action = { + Box( + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing24, + bottom = TangemTheme.dimens.spacing6, + ), + contentAlignment = Alignment.CenterEnd, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + RectangleShimmer( + modifier = Modifier + .height(TangemTheme.dimens.size16) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + } + }, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + SecurityScoreBlock( + state = SecurityScoreUM( + score = 3.5f, + description = "Based on 3 ratings", + onInfoClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + SecurityScorePlaceHolder( + modifier = Modifier.fillMaxWidth(), + ) + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt new file mode 100644 index 0000000000..40cc2fc2f5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt @@ -0,0 +1,142 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +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.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData + +internal fun LazyListScope.tokenMarketDetailsBody(state: MarketsTokenDetailsUM.Body) { + when (state) { + MarketsTokenDetailsUM.Body.Loading -> { + loading() + } + is MarketsTokenDetailsUM.Body.Content -> { + if (state.description != null) { + description(state.description) + } + + infoBlocksList(state.infoBlocks) + } + is MarketsTokenDetailsUM.Body.Error -> { + error(state) + } + MarketsTokenDetailsUM.Body.Nothing -> { + // Do nothing + } + } +} + +private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) { + item("body-error") { + Box(Modifier.fillMaxWidth()) { + UnableToLoadData( + modifier = Modifier + .align(Alignment.Center) + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing40, + ), + onRetryClick = state.onLoadRetryClick, + ) + } + } +} + +private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) { + item("description") { + Description( + modifier = Modifier.blockPaddings(), + description = description.shortDescription, + hasFullDescription = description.fullDescription != null, + onReadMoreClick = description.onReadMoreClick, + ) + } +} + +internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) { + if (state.insights != null) { + item("insights") { + InsightsBlock( + modifier = Modifier.blockPaddings(), + state = state.insights, + ) + } + } + + if (state.securityScore != null) { + item("securityScore") { + SecurityScoreBlock( + modifier = Modifier.blockPaddings(), + state = state.securityScore, + ) + } + } + + if (state.metrics != null) { + item("metrics") { + MetricsBlock( + modifier = Modifier.blockPaddings(), + state = state.metrics, + ) + } + } + + if (state.pricePerformance != null) { + item("pricePerformance") { + PricePerformanceBlock( + modifier = Modifier.blockPaddings(), + state = state.pricePerformance, + ) + } + } + + if (state.links != null) { + item("links") { + LinksBlock( + modifier = Modifier.blockPaddings(), + state = state.links, + ) + } + } +} + +private fun LazyListScope.loading() { + item("description-loading") { + DescriptionPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("insights-loading") { + InsightsBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("securityScore-loading") { + SecurityScorePlaceHolder(modifier = Modifier.blockPaddings()) + } + + item("metrics-loading") { + MetricsBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("pricePerformance-loading") { + PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("links-loading") { + LinksBlockPlaceholder(modifier = Modifier.blockPaddings()) + } +} + +@Composable +private fun Modifier.blockPaddings(): Modifier { + return this.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing12, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt new file mode 100644 index 0000000000..858fc017c5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt @@ -0,0 +1,9 @@ +package com.tangem.features.markets.details.impl.ui.state + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference + +internal data class InfoBottomSheetContent( + val title: TextReference, + val body: TextReference, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoPointUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoPointUM.kt new file mode 100644 index 0000000000..383db4e627 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoPointUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.markets.details.impl.ui.state + +import com.tangem.core.ui.extensions.TextReference + +internal data class InfoPointUM( + val title: TextReference, + val value: String, + val change: ChangeType? = null, + val onInfoClick: (() -> Unit)? = null, +) { + enum class ChangeType { + UP, DOWN + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt new file mode 100644 index 0000000000..9156f9bf1b --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.markets.details.impl.ui.state + +import kotlinx.collections.immutable.ImmutableList + +internal data class InsightsUM( + val h24Info: ImmutableList, + val weekInfo: ImmutableList, + val monthInfo: ImmutableList, + val onInfoClick: () -> Unit, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt new file mode 100644 index 0000000000..d1a6d13860 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt @@ -0,0 +1,19 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class LinksUM( + val officialLinks: ImmutableList, + val social: ImmutableList, + val repository: ImmutableList, + val blockchainSite: ImmutableList, + val onLinkClick: (Link) -> Unit, +) { + data class Link( + @DrawableRes val iconRes: Int, + val title: TextReference, + val url: String, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt new file mode 100644 index 0000000000..473bc3a518 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt @@ -0,0 +1,71 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.charts.state.MarketChartLook +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.extensions.TextReference +import com.tangem.domain.markets.PriceChangeInterval +import java.math.BigDecimal + +internal data class MarketsTokenDetailsUM( + val tokenName: String, + val priceText: String, + val iconUrl: String, + val dateTimeText: TextReference, + val priceChangePercentText: String, + val priceChangeType: PriceChangeType, + val selectedInterval: PriceChangeInterval, + val markerSet: Boolean, + val chartState: ChartState, + val onSelectedIntervalChange: (PriceChangeInterval) -> Unit, + val infoBottomSheet: TangemBottomSheetConfig, + val triggerPriceChange: StateEvent, + val body: Body, +) { + + data class ChartState( + val status: Status, + val dataProducer: MarketChartDataProducer, + val chartLook: MarketChartLook, + val onLoadRetryClick: () -> Unit, + val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit, + ) { + enum class Status { + LOADING, ERROR, DATA + } + } + + data class InformationBlocks( + val insights: InsightsUM?, + val securityScore: SecurityScoreUM?, + val metrics: MetricsUM?, + val pricePerformance: PricePerformanceUM?, + val links: LinksUM?, + ) + + @Immutable + sealed interface Body { + + data class Error( + val onLoadRetryClick: () -> Unit, + ) : Body + + data object Loading : Body + + data class Content( + val description: Description?, + val infoBlocks: InformationBlocks, + ) : Body + + data object Nothing : Body + } + + data class Description( + val shortDescription: TextReference, + val fullDescription: TextReference?, + val onReadMoreClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt new file mode 100644 index 0000000000..b1982093d4 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.markets.details.impl.ui.state + +import kotlinx.collections.immutable.PersistentList + +internal data class MetricsUM( + val metrics: PersistentList, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt new file mode 100644 index 0000000000..e56e10312e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.annotation.FloatRange + +internal data class PricePerformanceUM( + val h24: Value, + val month: Value, + val all: Value, +) { + data class Value( + val low: String, + val high: String, + @FloatRange(from = 0.0, to = 1.0) val indicatorFraction: Float, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/SecurityScoreUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/SecurityScoreUM.kt new file mode 100644 index 0000000000..35f35d4b9c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/SecurityScoreUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.annotation.FloatRange + +internal data class SecurityScoreUM( + @FloatRange(from = 0.0, to = 5.0) val score: Float, + val description: String, + val onInfoClick: () -> Unit, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt index f88b0b5ad0..c48bcbd802 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt @@ -1,7 +1,7 @@ package com.tangem.features.markets.di -import com.tangem.features.markets.component.MarketsListComponent -import com.tangem.features.markets.component.impl.DefaultMarketsListComponent +import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.DefaultMarketsEntryComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,5 +14,5 @@ internal interface ComponentModule { @Binds @Singleton - fun bindMarketsListComponent(factory: DefaultMarketsListComponent.Factory): MarketsListComponent.Factory + fun bindMarketsListComponent(factory: DefaultMarketsEntryComponent.Factory): MarketsEntryComponent.Factory } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt new file mode 100644 index 0000000000..6843a4d610 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.markets.tokenlist.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.component.BottomSheetState + +@Stable +interface MarketsTokenListComponent { + + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory { + fun create( + context: AppComponentContext, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + ): MarketsTokenListComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt new file mode 100644 index 0000000000..64c1f0f751 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt @@ -0,0 +1,70 @@ +package com.tangem.features.markets.tokenlist.impl + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel +import com.tangem.features.markets.tokenlist.impl.ui.MarketsList +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class DefaultMarketsTokenListComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val onTokenSelected: (TokenMarket, AppCurrency) -> Unit, +) : AppComponentContext by appComponentContext, MarketsTokenListComponent { + + private val model: MarketsListModel = getOrCreateModel() + + init { + model.tokenSelected + .onEach { onTokenSelected(it.first, it.second) } + .launchIn(componentScope) + } + + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + + val state by model.state.collectAsStateWithLifecycle() + val bsState by bottomSheetState + + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState + } + + MarketsList( + modifier = modifier, + state = state, + onHeaderSizeChange = onHeaderSizeChange, + bottomSheetState = bsState, + ) + } + + @AssistedFactory + interface Factory : MarketsTokenListComponent.Factory { + override fun create( + context: AppComponentContext, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + ): DefaultMarketsTokenListComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..bb44e19164 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.tokenlist.impl.di + +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import com.tangem.features.markets.tokenlist.impl.DefaultMarketsTokenListComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindMarketsTokenListComponent( + factory: DefaultMarketsTokenListComponent.Factory, + ): MarketsTokenListComponent.Factory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ModelModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ModelModule.kt similarity index 78% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ModelModule.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ModelModule.kt index 9ee24735da..17aa3f4cd5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ModelModule.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ModelModule.kt @@ -1,8 +1,8 @@ -package com.tangem.features.markets.di +package com.tangem.features.markets.tokenlist.impl.di import com.tangem.core.decompose.di.DecomposeComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.markets.model.MarketsListModel +import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt similarity index 85% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt index 97fc37c161..b2d0ca1844 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.model +package com.tangem.features.markets.tokenlist.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse @@ -7,11 +7,13 @@ import com.tangem.core.decompose.model.Model import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.model.statemanager.MarketsListUMStateManager -import com.tangem.features.markets.model.statemanager.MarketsListBatchFlowManager -import com.tangem.features.markets.ui.entity.ListUM -import com.tangem.features.markets.ui.entity.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListUMStateManager +import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListBatchFlowManager +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -32,6 +34,8 @@ internal class MarketsListModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, ) : Model() { + private var updateQuotesJob = JobHolder() + private val currentAppCurrency = getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } @@ -44,11 +48,12 @@ internal class MarketsListModel @Inject constructor( private val visibleItemIds = MutableStateFlow>(emptyList()) private val marketsListUMStateManager = MarketsListUMStateManager( + currentVisibleIds = Provider { visibleItemIds.value }, onLoadMoreUiItems = { activeListManager.loadMore() }, visibleItemsChanged = { visibleItemIds.value = it }, onRetryButtonClicked = { activeListManager.reload() }, + onTokenClick = { onTokenUIClicked(it) }, ) - private val mainMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, @@ -59,6 +64,7 @@ internal class MarketsListModel @Inject constructor( modelScope = modelScope, dispatchers = dispatchers, ) + private val searchMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, @@ -72,7 +78,12 @@ internal class MarketsListModel @Inject constructor( private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager + private val _tokenSelected = MutableSharedFlow>() + + val tokenSelected = _tokenSelected.asSharedFlow() + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + val isVisibleOnScreen = MutableStateFlow(false) val state = marketsListUMStateManager.state.asStateFlow() @@ -169,6 +180,7 @@ internal class MarketsListModel @Inject constructor( } .distinctUntilChanged() .collectLatest { visibleBatchKeys -> + // TODO load batch on scroll heat area activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval) } } @@ -189,9 +201,12 @@ internal class MarketsListModel @Inject constructor( modelScope.launch { marketsListUMStateManager.searchQueryFlow - .filter { it.isNotEmpty() } .debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS) - .filter { activeListManager == searchMarketsListManager } + .distinctUntilChanged() + .onEach { + if (it.isEmpty()) searchMarketsListManager.clearStateAndStopAllActions() + } + .filter { it.isNotEmpty() && activeListManager == searchMarketsListManager } .collectLatest { searchMarketsListManager.reload(searchText = it) } @@ -210,14 +225,24 @@ internal class MarketsListModel @Inject constructor( mainMarketsListManager.reload() } - private var updateQuotesJob = JobHolder() + private fun onTokenUIClicked(token: MarketsListItemUM) { + modelScope.launch { + activeListManager.getTokenById(token.id)?.let { found -> + _tokenSelected.emit(found to currentAppCurrency.value) + } + } + } + private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { launch { while (true) { delay(timeMillis) // Update quotes only when the container bottom sheet is in the expanded state containerBottomSheetState.first { it == BottomSheetState.EXPANDED } - activeListManager.updateQuotes() // TODO update a batch that is currently on screen + // and is visible on the screen + isVisibleOnScreen.first { it } + + activeListManager.updateQuotes() } }.saveIn(updateQuotesJob) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt similarity index 69% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt index c2badcd569..c43e29f406 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt @@ -1,15 +1,16 @@ -package com.tangem.features.markets.model.converters +package com.tangem.features.markets.tokenlist.impl.model.converters -import com.tangem.common.ui.charts.state.DefaultPointValuesConverter +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal import java.math.RoundingMode @@ -18,6 +19,8 @@ internal class MarketsTokenItemConverter( private val appCurrency: AppCurrency, ) : Converter { + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = false) + override fun convert(value: TokenMarket): MarketsListItemUM { return MarketsListItemUM( id = value.id, @@ -30,7 +33,7 @@ internal class MarketsTokenItemConverter( trendPercentText = value.getTrendPercent(), trendType = value.getTrendType(), chardData = value.getChartData(), - showUnder100kMarketCap = value.isUnder100kMarketCap(), + isUnder100kMarketCap = value.isUnder100kMarketCap(), ) } @@ -45,17 +48,17 @@ internal class MarketsTokenItemConverter( ratingPosition = new.marketRating?.toString(), marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() }, iconUrl = new.imageUrlLarge, - price = ifChanged(prev = prev.tokenQuotes, new = new.tokenQuotes, prevR = prevUI.price) { + price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) { new.getCurrentPrice( prev = prev, ) }, trendPercentText = ifChanged( - prev.tokenQuotes, - new.tokenQuotes, + prev.tokenQuotesShort, + new.tokenQuotesShort, prevUI.trendPercentText, ) { new.getTrendPercent() }, - trendType = ifChanged(prev.tokenQuotes, new.tokenQuotes, prevUI.trendType) { new.getTrendType() }, + trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() }, chardData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chardData) { new.getChartData() }, ) } @@ -67,24 +70,25 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getMarketCap(): String? { val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null - return BigDecimalFormatter.formatCompactAmount( - value, + return BigDecimalFormatter.formatCompactFiatAmount( + amount = value, fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, + threeDigitsMethod = true, ) } private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price { - val prevPrice = prev?.tokenQuotes?.currentPrice + val prevPrice = prev?.tokenQuotesShort?.currentPrice - val priceText = BigDecimalFormatter.formatFiatAmountUncapped( - fiatAmount = tokenQuotes.currentPrice, + val priceText = BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = tokenQuotesShort.currentPrice, fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, ) val changeType = if (prevPrice != null) { - if (tokenQuotes.currentPrice > prevPrice) { + if (tokenQuotesShort.currentPrice > prevPrice) { PriceChangeType.UP } else { PriceChangeType.DOWN @@ -107,10 +111,10 @@ internal class MarketsTokenItemConverter( } return chart?.let { ct -> - DefaultPointValuesConverter.convert( + priceAndTimePointValuesConverter.convert( MarketChartData.Data( - y = ct.priceY, - x = ct.timeStamp.map { it.toBigDecimal() }, + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), ), ) } @@ -118,9 +122,9 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getTrendType(): PriceChangeType { val percent = when (currentTrendInterval) { - TrendInterval.H24 -> tokenQuotes.h24Percent() - TrendInterval.D7 -> tokenQuotes.weekPercent() - TrendInterval.M1 -> tokenQuotes.monthPercent() + TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent + TrendInterval.D7 -> tokenQuotesShort.weekChangePercent + TrendInterval.M1 -> tokenQuotesShort.monthChangePercent }.setScale(2, RoundingMode.UP) return when (percent.compareTo(BigDecimal.ZERO)) { @@ -132,9 +136,9 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getTrendPercent(): String { val percent = when (currentTrendInterval) { - TrendInterval.H24 -> tokenQuotes.h24Percent() - TrendInterval.D7 -> tokenQuotes.weekPercent() - TrendInterval.M1 -> tokenQuotes.monthPercent() + TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent + TrendInterval.D7 -> tokenQuotesShort.weekChangePercent + TrendInterval.M1 -> tokenQuotesShort.monthChangePercent } return BigDecimalFormatter.formatPercent( @@ -144,7 +148,7 @@ internal class MarketsTokenItemConverter( } private fun TokenMarket.isUnder100kMarketCap(): Boolean { - return tokenQuotes.currentPrice.compareTo(decimal100k) == -1 + return marketCap?.let { it < decimal100k } ?: true } private companion object { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt similarity index 63% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt index ae897a5b9a..bc5dcaed36 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt @@ -1,23 +1,27 @@ -package com.tangem.features.markets.model.statemanager +package com.tangem.features.markets.tokenlist.impl.model.statemanager import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* -import com.tangem.features.markets.model.converters.MarketsTokenItemConverter -import com.tangem.features.markets.model.utils.logAction -import com.tangem.features.markets.model.utils.logStatus -import com.tangem.features.markets.model.utils.logUpdateResults -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval -import com.tangem.features.markets.ui.entity.SortByTypeUM -import com.tangem.pagination.* +import com.tangem.features.markets.tokenlist.impl.model.converters.MarketsTokenItemConverter +import com.tangem.features.markets.tokenlist.impl.model.utils.logAction +import com.tangem.features.markets.tokenlist.impl.model.utils.logStatus +import com.tangem.features.markets.tokenlist.impl.model.utils.logUpdateResults +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.PaginationStatus 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.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch private const val LOG_EVENTS = true @@ -33,6 +37,7 @@ internal class MarketsListBatchFlowManager( private val dispatchers: CoroutineDispatcherProvider, ) { private val actionsFlow = MutableSharedFlow>() + private val updateStateJob = JobHolder() private val batchFlow = getMarketsTokenListFlowUseCase( batchingContext = TokenListBatchingContext( @@ -42,6 +47,9 @@ internal class MarketsListBatchFlowManager( batchFlowType = batchFlowType, ) + private val resultBatches = MutableStateFlow(ResultBatches()) + private val uiBatches = resultBatches.map { it.uiBatches } + val uiItems: StateFlow> get() = uiBatches .map { batches -> @@ -97,16 +105,20 @@ internal class MarketsListBatchFlowManager( initialValue = false, ) - private val uiBatches = MutableStateFlow>>>(emptyList()) - init { batchFlow.state .map { it.data } .distinctUntilChanged { a, b -> - a.size == b.size && a.map { it.data }.flatten() == b.map { it.data }.flatten() + a.size == b.size && + a.map { it.key } == b.map { it.key } && + a.map { it.data }.flatten() == b.map { it.data }.flatten() } - .onEachWithPrevious { prev, list -> - updateState(prev, list) + .onEach { + coroutineScope { + launch { + updateState(it) + }.saveIn(updateStateJob) + } } .flowOn(dispatchers.default) .launchIn(modelScope) @@ -127,58 +139,75 @@ internal class MarketsListBatchFlowManager( } } - private fun updateState( - previousList: List>>?, - list: List>>, - forceUpdate: Boolean = false, - ) = uiBatches.update { items -> - val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency()) + private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = + withContext(dispatchers.default) { + resultBatches.update { resultBatches -> + val items = resultBatches.uiBatches + val previousList = resultBatches.processedItems - if (previousList == null || list.size < previousList.size || forceUpdate) { - list.map { - Batch( - key = it.key, - data = converter.convertList(it.data), + val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency()) + + if (newList.isEmpty()) { + return@update ResultBatches(processedItems = emptyList()) + } + + val isInitialLoading = + forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key + + val outItems = if (isInitialLoading) { + newList.map { + Batch( + key = it.key, + data = converter.convertList(it.data), + ) + } + } else { + previousList!! + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + items + newBatches.map { + Batch( + key = it.key, + data = converter.convertList(it.data), + ) + } + } else { + items.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (previousList == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, marketsListItemUM -> + val prevItem = prevBatch.data[index] + val newItem = newBatch.data[index] + + converter.update( + prevItem, + marketsListItemUM, + newItem, + ) + }, + ) + } + } + } + + currentCoroutineContext().ensureActive() + + ResultBatches( + uiBatches = outItems, + processedItems = newList, ) } - } else { - if (previousList.size != list.size) { - val keysToAdd = list.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = list.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { - Batch( - key = it.key, - data = converter.convertList(it.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = list[batchIndex] - if (previousList == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data[index] - val newItem = newBatch.data[index] - - converter.update( - prevItem, - marketsListItemUM, - newItem, - ) - }, - ) - } - } } - } fun reload(searchText: String? = null) { modelScope.launch { - uiBatches.value = emptyList() + resultBatches.value = ResultBatches() actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( @@ -188,7 +217,6 @@ internal class MarketsListBatchFlowManager( } else { searchText ?: currentSearchText() }, - showUnder100kMarketCapTokens = false, // TODO priceChangeInterval = currentTrendInterval().toBatchRequestInterval(), order = currentSortByType().toRequestOrder(), ), @@ -206,12 +234,14 @@ internal class MarketsListBatchFlowManager( fun updateUIWithSameState() { modelScope.launch(dispatchers.default) { val current = batchFlow.state.value.data - updateState(current, current, forceUpdate = true) - } + updateState(current, forceUpdate = true) + }.saveIn(updateStateJob) } fun loadCharts(batchKeys: Set, interval: TrendInterval) { - modelScope.launch(dispatchers.default) { + if (batchKeys.isEmpty()) return + + modelScope.launch { val currentData = batchFlow.state.value.data val alreadyLoadedChartsBatchKeys = currentData .filter { @@ -233,7 +263,7 @@ internal class MarketsListBatchFlowManager( BatchAction.UpdateBatches( keys = batchesKeysToLoad, updateRequest = TokenMarketUpdateRequest.UpdateChart( - interval = interval.toRequestInterval(), + interval = interval.toBatchRequestInterval(), currency = currentAppCurrency().code, ), async = true, @@ -266,7 +296,7 @@ internal class MarketsListBatchFlowManager( } fun clearStateAndStopAllActions() { - uiBatches.value = emptyList() + resultBatches.value = ResultBatches() modelScope.launch { actionsFlow.emit(BatchAction.Reset) } @@ -281,6 +311,10 @@ internal class MarketsListBatchFlowManager( .toSet() } + fun getTokenById(id: String): TokenMarket? { + return batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id } + } + private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order { return when (this) { SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating @@ -299,20 +333,8 @@ internal class MarketsListBatchFlowManager( } } - private fun TrendInterval.toRequestInterval(): PriceChangeInterval { - return when (this) { - TrendInterval.H24 -> PriceChangeInterval.H24 - TrendInterval.D7 -> PriceChangeInterval.WEEK - TrendInterval.M1 -> PriceChangeInterval.MONTH - } - } - - private fun Flow.onEachWithPrevious(operation: suspend (prev: T?, value: T) -> Unit): Flow = flow { - var prev: T? = null - collect { value -> - operation(prev, value) - prev = value - emit(value) - } - } + private data class ResultBatches( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = null, + ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt similarity index 57% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt index d845850f44..50170ce417 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.model.statemanager +package com.tangem.features.markets.tokenlist.impl.model.statemanager import androidx.compose.runtime.Stable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -7,19 +7,24 @@ import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM -import com.tangem.features.markets.ui.entity.ListUM -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.entity.MarketsListUM -import com.tangem.features.markets.ui.entity.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM +import com.tangem.utils.Provider import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* @Stable internal class MarketsListUMStateManager( + private val currentVisibleIds: Provider>, private val onLoadMoreUiItems: () -> Unit, private val visibleItemsChanged: (itemsKeys: List) -> Unit, private val onRetryButtonClicked: () -> Unit, + private val onTokenClick: (MarketsListItemUM) -> Unit, ) { private var sortByBottomSheetIsShown @@ -98,21 +103,92 @@ internal class MarketsListUMStateManager( it.copy(list = ListUM.Loading) } else -> { - it.copy( - list = ListUM.Content( - items = uiItems, - loadMore = onLoadMoreUiItems, - visibleIdsChanged = visibleItemsChanged, - showUnder100kTokens = true, - onShowTokensUnder100kClicked = { }, - triggerScrollReset = consumedEvent(), - ), - ) + it.updateItems(newItems = uiItems) } } } } + private fun MarketsListUM.updateItems(newItems: ImmutableList): MarketsListUM { + val currentState = this + val isNextPageInSearch = isInSearchState && (this.list as? ListUM.Content)?.showUnder100kTokens == true + var searchUiItemsCached: ImmutableList = persistentListOf() + + val items = when { + isInSearchState && isNextPageInSearch.not() -> { + searchUiItemsCached = newItems + val filtered = newItems.filter { item -> item.isUnder100kMarketCap.not() }.toImmutableList() + + if (filtered.size == newItems.size) { + return currentState.copy(list = generalContentState(newItems)) + } else { + filtered + } + } + else -> { + searchUiItemsCached = persistentListOf() + newItems + } + } + + val itemsWithFilteredPriceChange = items.filterPriceChangeByVisibility() + + return currentState.copy( + list = ListUM.Content( + items = itemsWithFilteredPriceChange, + loadMore = onLoadMoreUiItems, + visibleIdsChanged = visibleItemsChanged, + showUnder100kTokens = isInSearchState.not() || isNextPageInSearch, + onShowTokensUnder100kClicked = { + if (searchUiItemsCached.isNotEmpty()) { + state.update { s -> + if (s.list is ListUM.Content) { + s.copy( + list = s.list.copy( + items = searchUiItemsCached, + showUnder100kTokens = true, + ), + ) + } else { + s + } + } + } + }, + triggerScrollReset = consumedEvent(), + onItemClick = onTokenClick, + ), + ) + } + + // Show price change animation for visible items only + private fun ImmutableList.filterPriceChangeByVisibility(): ImmutableList { + val visibleItemIds = currentVisibleIds() + return map { + it.copy( + price = it.price.copy( + changeType = if (visibleItemIds.contains(it.id)) { + it.price.changeType + } else { + null + }, + ), + ) + }.toImmutableList() + } + + private fun generalContentState(newItems: ImmutableList): ListUM.Content { + return ListUM.Content( + items = newItems, + loadMore = onLoadMoreUiItems, + visibleIdsChanged = visibleItemsChanged, + showUnder100kTokens = true, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), + onItemClick = onTokenClick, + ) + } + private fun state(): MarketsListUM = MarketsListUM( list = ListUM.Loading, searchBar = SearchBarUM( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/utils/LoggingUtils.kt similarity index 96% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/utils/LoggingUtils.kt index e9cc0e8b31..8b612f6db5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/utils/LoggingUtils.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.model.utils +package com.tangem.features.markets.tokenlist.impl.model.utils import com.tangem.domain.markets.TokenMarket import com.tangem.domain.markets.TokenMarketListConfig diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt similarity index 69% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt index 3ff1f1d4e7..6dcb360962 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt @@ -1,16 +1,15 @@ -package com.tangem.features.markets.ui +package com.tangem.features.markets.tokenlist.impl.ui +import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -32,17 +31,18 @@ import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.markets.component.BottomSheetState import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.components.MarketsListLazyColumn -import com.tangem.features.markets.ui.components.MarketsListSortByBottomSheet -import com.tangem.features.markets.ui.entity.ListUM -import com.tangem.features.markets.ui.entity.MarketsListUM -import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM -import com.tangem.features.markets.ui.entity.SortByTypeUM -import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider +import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListLazyColumn +import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListSortByBottomSheet +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -68,23 +68,28 @@ internal fun MarketsList( @Composable private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) { val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier .fillMaxSize() .imePadding() - .background(color = TangemTheme.colors.background.primary), + .drawBehind { drawRect(background) }, ) { SearchBar( modifier = Modifier - .background(color = TangemTheme.colors.background.primary) + .drawBehind { drawRect(background) } .padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing16, bottom = TangemTheme.dimens.spacing4, ) .onGloballyPositioned { - with(density) { onHeaderSizeChange(it.size.height.toDp()) } + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } + } }, state = state.searchBar, ) @@ -225,44 +230,52 @@ private fun KeyboardEvents(isSortByBottomSheetShown: Boolean, bottomSheetState: //region: Preview @Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun Preview() { - TangemThemePreview { - MarketsList( - state = MarketsListUM( - list = ListUM.Content( - items = MarketChartListItemPreviewDataProvider().values - .flatMap { item -> List(size = 10) { item } } - .mapIndexed { index, item -> - item.copy(id = index.toString()) - } - .toImmutableList(), - showUnder100kTokens = false, - loadMore = {}, - visibleIdsChanged = {}, - onShowTokensUnder100kClicked = {}, - triggerScrollReset = consumedEvent(), + TangemThemePreview(alwaysShowBottomSheets = false) { + val primaryBackground = TangemTheme.colors.background.primary + + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(primaryBackground) }, + ) { + MarketsList( + state = MarketsListUM( + list = ListUM.Content( + items = MarketChartListItemPreviewDataProvider().values + .flatMap { item -> List(size = 10) { item } } + .mapIndexed { index, item -> + item.copy(id = index.toString()) + } + .toImmutableList(), + showUnder100kTokens = false, + loadMore = {}, + visibleIdsChanged = {}, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), + onItemClick = {}, + ), + searchBar = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = { }, + ), + selectedSortBy = SortByTypeUM.Rating, + selectedInterval = MarketsListUM.TrendInterval.H24, + onIntervalClick = {}, + onSortByButtonClick = {}, + sortByBottomSheet = TangemBottomSheetConfig( + isShow = false, + onDismissRequest = {}, + content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, + ), ), - searchBar = SearchBarUM( - placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = { }, - ), - selectedSortBy = SortByTypeUM.Rating, - selectedInterval = MarketsListUM.TrendInterval.H24, - onIntervalClick = {}, - onSortByButtonClick = {}, - sortByBottomSheet = TangemBottomSheetConfig( - isShow = false, - onDismissRequest = {}, - content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, - ), - ), - onHeaderSizeChange = {}, - bottomSheetState = BottomSheetState.EXPANDED, - ) + onHeaderSizeChange = {}, + bottomSheetState = BottomSheetState.EXPANDED, + ) + } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt similarity index 96% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index 847f063309..7658893fb7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.animation.Animatable @@ -39,14 +39,15 @@ import com.tangem.core.ui.components.* import com.tangem.core.ui.components.currency.icon.CoinIcon import com.tangem.core.ui.components.marketprice.PriceChangeInPercent import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.windowsize.WindowSizeType import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider import com.tangem.utils.StringsSigns.MINUS import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -97,14 +98,14 @@ fun MarketsListItem( .collect { val border = -actionWidthPx * SWIPE_THRESHOLD_PERCENT if (it < border && actionPerformed.not()) { - hapticManager.vibrateLong() + hapticManager.perform(TangemHapticEffect.View.GestureThresholdActivate) actionPerformed = true releasePerformed = false } if (it > border) { if (releasePerformed.not()) { - hapticManager.vibrateShort() + hapticManager.perform(TangemHapticEffect.View.GestureThresholdDeactivate) releasePerformed = true } actionPerformed = false diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItemPlaceholder.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItemPlaceholder.kt similarity index 79% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItemPlaceholder.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItemPlaceholder.kt index 251844c2d6..692b663cc8 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItemPlaceholder.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItemPlaceholder.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.foundation.background @@ -34,11 +34,11 @@ fun MarketsListItemPlaceholder() { SpacerW12() Column(modifier = Modifier.weight(1f)) { - Row( + Box( modifier = Modifier .fillMaxWidth() .padding(vertical = TangemTheme.dimens.spacing4), - horizontalArrangement = Arrangement.SpaceBetween, + contentAlignment = Alignment.CenterStart, ) { RectangleShimmer( modifier = Modifier @@ -46,23 +46,15 @@ fun MarketsListItemPlaceholder() { .height(sp12), radius = TangemTheme.dimens.radius3, ) - SpacerW8() - RectangleShimmer( - modifier = Modifier - .width(TangemTheme.dimens.size70) - .height(sp12), - radius = TangemTheme.dimens.radius3, - ) } SpacerH(height = TangemTheme.dimens.spacing2) - Row( + Box( modifier = Modifier .fillMaxWidth() .padding(vertical = TangemTheme.dimens.spacing2), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, + contentAlignment = Alignment.CenterStart, ) { RectangleShimmer( modifier = Modifier @@ -70,12 +62,6 @@ fun MarketsListItemPlaceholder() { .height(sp12), radius = TangemTheme.dimens.radius3, ) - RectangleShimmer( - modifier = Modifier - .width(TangemTheme.dimens.size52) - .height(sp12), - radius = TangemTheme.dimens.radius3, - ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt similarity index 86% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index dd7753fb72..0da161d62a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -9,10 +9,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import com.tangem.core.ui.components.buttons.SecondarySmallButton @@ -20,11 +16,13 @@ import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.disableNestedScroll import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.entity.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM import kotlinx.coroutines.launch private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50 +private const val TOKEN_LAZY_LIST_ID_SEPARATOR = "***" @Composable @Suppress("LongMethod") @@ -53,7 +51,7 @@ internal fun MarketsListLazyColumn( if (state is ListUM.Loading) { LazyColumn( - modifier = Modifier.nestedScroll(DisableParentConnection), + modifier = Modifier.disableNestedScroll(), state = rememberLazyListState(), contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = false, @@ -64,7 +62,7 @@ internal fun MarketsListLazyColumn( } } else { LazyColumn( - modifier = modifier.nestedScroll(DisableParentConnection), + modifier = modifier.disableNestedScroll(), state = lazyListState, contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = true, @@ -89,9 +87,12 @@ internal fun MarketsListLazyColumn( is ListUM.Content -> { items( items = state.items, - key = { it.id }, + key = { it.id + TOKEN_LAZY_LIST_ID_SEPARATOR + it.marketCap.toString() }, ) { item -> - MarketsListItem(model = item) + MarketsListItem( + model = item, + onClick = { state.onItemClick(item) }, + ) } if (isInSearchMode && state.showUnder100kTokens.not()) { @@ -114,8 +115,11 @@ internal fun MarketsListLazyColumn( buffer = LOAD_NEXT_PAGE_ON_END_INDEX, onLoadMore = remember(state) { { - if (state is ListUM.Content) { + if (state is ListUM.Content && state.showUnder100kTokens) { state.loadMore() + true + } else { + false } } }, @@ -181,7 +185,9 @@ private fun SearchNothingFoundText(modifier: Modifier = Modifier) { private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { val visibleItems by remember { derivedStateOf { - listState.layoutInfo.visibleItemsInfo.mapNotNull { it.key as? String } + listState.layoutInfo.visibleItemsInfo.mapNotNull { + (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() + } } } @@ -193,7 +199,7 @@ private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { } @Composable -fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer: Int = 2) { +fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) { val loadMore by remember { derivedStateOf { val layoutInfo = listState.layoutInfo @@ -209,14 +215,7 @@ fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer LaunchedEffect(loadMore) { if (loadMore && !emitted) { - emitted = true - onLoadMore() + emitted = onLoadMore() } } -} - -private object DisableParentConnection : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return available.copy(x = 0f) - } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt similarity index 93% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt index 4ee563d08c..b5c0966102 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.foundation.background @@ -19,8 +19,8 @@ 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.markets.impl.R -import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM -import com.tangem.features.markets.ui.entity.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM @Composable fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt similarity index 96% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt index cf58422fb5..4cec85a065 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/preview/MarketChartListItemPreviewDataProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt similarity index 82% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/preview/MarketChartListItemPreviewDataProvider.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt index 782d4bfbc8..41d1596f68 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/preview/MarketChartListItemPreviewDataProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt @@ -1,10 +1,11 @@ @file:Suppress("MagicNumber") -package com.tangem.features.markets.ui.preview +package com.tangem.features.markets.tokenlist.impl.ui.preview import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.features.markets.ui.entity.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import kotlinx.collections.immutable.persistentListOf internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider( collection = listOf( @@ -19,7 +20,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -45,7 +46,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.DOWN, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -59,7 +60,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -73,7 +74,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -87,7 +88,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), ), diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt similarity index 77% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt index a561cf8517..9f3821c9c5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.entity +package com.tangem.features.markets.tokenlist.impl.ui.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.charts.state.MarketChartLook @@ -17,13 +17,12 @@ data class MarketsListItemUM( val trendPercentText: String, val trendType: PriceChangeType, val chardData: MarketChartRawData?, - val showUnder100kMarketCap: Boolean = false, + val isUnder100kMarketCap: Boolean = false, ) { val chartType: MarketChartLook.Type = when (trendType) { - PriceChangeType.UP, - PriceChangeType.NEUTRAL, - -> MarketChartLook.Type.Growing + PriceChangeType.UP -> MarketChartLook.Type.Growing PriceChangeType.DOWN -> MarketChartLook.Type.Falling + PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral } @Immutable diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt similarity index 94% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt index 9167060698..7906cc1fb9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.entity +package com.tangem.features.markets.tokenlist.impl.ui.state import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -46,6 +46,7 @@ sealed class ListUM { val visibleIdsChanged: (List) -> Unit, val onShowTokensUnder100kClicked: () -> Unit, val triggerScrollReset: StateEvent, + val onItemClick: (MarketsListItemUM) -> Unit, ) : ListUM() data object Loading : ListUM() diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/SortByBottomSheetContentUM.kt similarity index 80% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/SortByBottomSheetContentUM.kt index af420055f5..f3b4e9682b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/SortByBottomSheetContentUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.entity +package com.tangem.features.markets.tokenlist.impl.ui.state import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent diff --git a/features/referral/data/build.gradle.kts b/features/referral/data/build.gradle.kts index 5ca72579ad..954bac9c2e 100644 --- a/features/referral/data/build.gradle.kts +++ b/features/referral/data/build.gradle.kts @@ -17,7 +17,7 @@ dependencies { implementation(projects.core.utils) /** Data modules */ - implementation(projects.data.tokens) + implementation(projects.data.common) /** Domain modules */ implementation(projects.domain.legacy) diff --git a/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt b/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt index 2d44c137ff..9a901c1b69 100644 --- a/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt +++ b/features/referral/data/src/main/java/com/tangem/feature/referral/data/ReferralRepositoryImpl.kt @@ -3,7 +3,7 @@ package com.tangem.feature.referral.data import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.StartReferralBody diff --git a/features/send/api/src/main/kotlin/com/tangem/features/send/api/featuretoggles/SendFeatureToggles.kt b/features/send/api/src/main/kotlin/com/tangem/features/send/api/featuretoggles/SendFeatureToggles.kt deleted file mode 100644 index 453a79b60c..0000000000 --- a/features/send/api/src/main/kotlin/com/tangem/features/send/api/featuretoggles/SendFeatureToggles.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.features.send.api.featuretoggles - -/** - * Send feature toggles - */ -interface SendFeatureToggles { - - /** Availability of redesigned send screen */ - val isRedesignedSendEnabled: Boolean - - /** Updates remote toggle */ - suspend fun fetchNewSendEnabled() -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/di/SendFeatureTogglesModule.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/di/SendFeatureTogglesModule.kt deleted file mode 100644 index 60c8b2822c..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/di/SendFeatureTogglesModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.tangem.features.send.impl.di - -import com.tangem.core.featuretoggle.manager.FeatureTogglesManager -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles -import com.tangem.features.send.impl.featuretoggles.DefaultSendFeatureToggles -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 - -/** - * DI module provides implementation of [SendFeatureToggles] - */ -@Module -@InstallIn(SingletonComponent::class) -internal object SendFeatureTogglesModule { - - @Provides - @Singleton - fun provideSendFeatureToggles( - featureTogglesManager: FeatureTogglesManager, - tangemTechApi: TangemTechApi, - dispatchers: CoroutineDispatcherProvider, - ): SendFeatureToggles { - return DefaultSendFeatureToggles( - featureTogglesManager = featureTogglesManager, - tangemTechApi = tangemTechApi, - dispatchers = dispatchers, - ) - } -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/featuretoggles/DefaultSendFeatureToggles.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/featuretoggles/DefaultSendFeatureToggles.kt deleted file mode 100644 index b36cba4cbd..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/featuretoggles/DefaultSendFeatureToggles.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.features.send.impl.featuretoggles - -import com.tangem.core.featuretoggle.manager.FeatureTogglesManager -import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.runCatching -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import timber.log.Timber - -/** - * Default implementation of Send feature toggles - * - * @property featureTogglesManager manager for getting information about the availability of feature toggles - * @property tangemTechApi api to get remote feature toggle for send - * @property dispatchers coroutine dispatchers - */ -internal class DefaultSendFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, - private val tangemTechApi: TangemTechApi, - private val dispatchers: CoroutineDispatcherProvider, -) : SendFeatureToggles { - - private val remoteSendEnabled: MutableStateFlow = MutableStateFlow(true) - - override val isRedesignedSendEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_SEND_SCREEN_ENABLED") && - remoteSendEnabled.value - - override suspend fun fetchNewSendEnabled() { - runCatching(dispatchers.io) { - tangemTechApi.getFeatures().getOrThrow() - }.onSuccess { response -> - remoteSendEnabled.update { response.isNewSendEnabled } - }.onFailure { - Timber.e(it.localizedMessage, "Unable to fetch new send toggle") - } - } -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt index fc7ae7daf1..4abbd3c665 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt @@ -68,13 +68,6 @@ internal sealed class SendAlertState { override val confirmButtonText: TextReference = resourceReference(R.string.common_continue) } - data class ReserveAmount(val amount: String) : SendAlertState() { - override val title: TextReference = - resourceReference(id = R.string.send_notification_invalid_reserve_amount_title, wrappedList(amount)) - override val message: TextReference = - resourceReference(id = R.string.send_notification_invalid_reserve_amount_text) - } - data class FeeUnreachableError( override val onConfirmClick: (() -> Unit), ) : SendAlertState() { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt index 852339a7ed..3424853d60 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt @@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.state import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.event.triggeredEvent -import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory @@ -22,13 +21,11 @@ import java.math.BigDecimal internal class SendEventStateFactory( private val stateRouterProvider: Provider, private val currentStateProvider: Provider, - private val cryptoCurrencyStatusProvider: Provider, private val clickIntents: SendClickIntents, private val feeStateFactory: FeeStateFactory, ) { private val sendTransactionErrorConverter by lazy(LazyThreadSafetyMode.NONE) { SendTransactionAlertConverter( - cryptoCurrencyStatusProvider = cryptoCurrencyStatusProvider, clickIntents = clickIntents, ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt index 017c91f8d4..8a455b1b25 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt @@ -105,6 +105,14 @@ internal sealed class SendNotification(val config: NotificationConfig) { onClick = onConfirmClick, ), ) + + data class ReserveAmount(val amount: String) : Error( + title = resourceReference( + id = R.string.send_notification_invalid_reserve_amount_title, + wrappedList(amount), + ), + subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text), + ) } sealed class Warning( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt index 7f6a8588b1..d37954025b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendTransactionAlertConverter.kt @@ -1,14 +1,10 @@ package com.tangem.features.send.impl.presentation.state -import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents -import com.tangem.utils.Provider import com.tangem.utils.converter.Converter internal class SendTransactionAlertConverter( - private val cryptoCurrencyStatusProvider: Provider, private val clickIntents: SendClickIntents, ) : Converter { override fun convert(value: SendTransactionError): SendAlertState? { @@ -42,12 +38,6 @@ internal class SendTransactionAlertConverter( cause = value.ex?.localizedMessage, onConfirmClick = { clickIntents.onFailedTxEmailClick(value.ex?.localizedMessage.orEmpty()) }, ) - is SendTransactionError.CreateAccountUnderfunded -> SendAlertState.ReserveAmount( - BigDecimalFormatter.formatWithSymbol( - amount = value.amount, - symbol = cryptoCurrencyStatusProvider().currency.symbol, - ), - ) else -> null } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt index a4e3bcbf9c..ae185f4c0c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt @@ -20,8 +20,8 @@ import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.repository.CurrencyChecksRepository -import com.tangem.domain.tokens.utils.convertToAmount import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase +import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.send.impl.R import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents @@ -31,6 +31,7 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils.isTezos import com.tangem.utils.Provider +import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -60,14 +61,15 @@ internal class SendNotificationFactory( .map { val state = currentStateProvider() val isEditState = stateRouterProvider().isEditState - val balance = cryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO + val balance = cryptoCurrencyStatusProvider().value.amount.orZero() val sendState = state.sendState ?: return@map persistentListOf() val feeState = state.getFeeState(isEditState) ?: return@map persistentListOf() val amountState = state.getAmountState(isEditState) as? AmountState.Data ?: return@map persistentListOf() - val amountValue = amountState.amountTextField.cryptoAmount.value ?: BigDecimal.ZERO - val feeValue = feeState.fee?.amount?.value ?: BigDecimal.ZERO - val reduceAmountBy = sendState.reduceAmountBy ?: BigDecimal.ZERO + val recipientAddress = state.recipientState?.addressTextField?.value.orEmpty() + val amountValue = amountState.amountTextField.cryptoAmount.value.orZero() + val feeValue = feeState.fee?.amount?.value.orZero() + val reduceAmountBy = sendState.reduceAmountBy.orZero() val isFeeCoverage = checkFeeCoverage( isSubtractAvailable = isSubtractAvailableProvider(), balance = balance, @@ -89,6 +91,7 @@ internal class SendNotificationFactory( addExceedsBalanceNotification(feeState.fee) addDustWarningNotificationForSpecificBlockchains(feeValue, sendingAmount) addTransactionLimitErrorNotification(feeValue, sendingAmount) + addReserveAmountErrorNotification(recipientAddress, sendingAmount) // warnings addExistentialWarningNotification(feeValue, amountValue) @@ -156,27 +159,29 @@ internal class SendNotificationFactory( } } - // todo temporarily disabling notification - // private suspend fun MutableList.addReserveAmountErrorNotification(recipientAddress: String) { - // val userWalletId = userWalletProvider().walletId - // val cryptoCurrency = cryptoCurrencyStatusProvider().currency - // val isAccountFunded = currencyChecksRepository.checkIfAccountFunded( - // userWalletId, - // cryptoCurrency.network, - // recipientAddress, - // ) - // val minimumAmount = currencyChecksRepository.getReserveAmount(userWalletId, cryptoCurrency.network) - // if (!isAccountFunded && minimumAmount != null && minimumAmount > BigDecimal.ZERO) { - // add( - // SendNotification.Error.ReserveAmountError( - // BigDecimalFormatter.formatCryptoAmount( - // cryptoAmount = minimumAmount, - // cryptoCurrency = cryptoCurrency, - // ), - // ), - // ) - // } - // } + private suspend fun MutableList.addReserveAmountErrorNotification( + recipientAddress: String, + sendingAmount: BigDecimal, + ) { + val userWalletId = userWalletProvider().walletId + val cryptoCurrency = cryptoCurrencyStatusProvider().currency + val isAccountFunded = currencyChecksRepository.checkIfAccountFunded( + userWalletId, + cryptoCurrency.network, + recipientAddress, + ) + val minimumAmount = currencyChecksRepository.getReserveAmount(userWalletId, cryptoCurrency.network) + if (!isAccountFunded && minimumAmount != null && minimumAmount > sendingAmount) { + add( + SendNotification.Error.ReserveAmount( + BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = minimumAmount, + cryptoCurrency = cryptoCurrency, + ), + ), + ) + } + } private suspend fun MutableList.addTransactionLimitErrorNotification( feeAmount: BigDecimal, @@ -442,7 +447,7 @@ internal class SendNotificationFactory( val sendingCurrency = cryptoCurrencyStatusProvider().currency validateTransactionUseCase( - amount = sendingAmount.convertToAmount(sendingCurrency), + amount = sendingAmount.convertToSdkAmount(sendingCurrency), fee = fee ?: return, memo = state.recipientState?.memoTextField?.value, destination = requireNotNull(state.recipientState?.addressTextField?.value), diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt index 3e676181c9..18d8413082 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.ui.SendDoneButtons -import com.tangem.common.ui.amountScreen.utils.getCryptoReference import com.tangem.common.ui.amountScreen.utils.getFiatString import com.tangem.core.ui.R import com.tangem.core.ui.components.Keyboard @@ -34,6 +33,7 @@ import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType @@ -186,16 +186,6 @@ private fun SendingText( fiatCurrencySymbol = feeState.appCurrency.symbol, fiatCurrencyCode = feeState.appCurrency.code, ) - val feeValue = if (feeState.isFeeConvertibleToFiat) { - getFiatString( - value = feeState.fee?.amount?.value, - rate = feeState.rate, - appCurrency = feeState.appCurrency, - ) - } else { - getCryptoReference(feeState.fee?.amount, feeState.isFeeApproximate)?.resolveReference().orEmpty() - } - val textResource = remember(uiState) { resourceReference( id = if (feeState.isFeeConvertibleToFiat) { @@ -203,7 +193,7 @@ private fun SendingText( } else { R.string.send_summary_transaction_description_no_fiat_fee }, - formatArgs = wrappedList(sendingValue, feeValue), + formatArgs = wrappedList(sendingValue, feeState.getFiatValue()), ) } Text( @@ -219,6 +209,22 @@ private fun SendingText( } } +private fun SendStates.FeeState.getFiatValue() = if (isFeeConvertibleToFiat) { + getFiatString( + value = fee?.amount?.value, + rate = rate, + appCurrency = appCurrency, + ) +} else { + val amount = fee?.amount + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = amount?.value, + cryptoCurrency = amount?.currencySymbol.orEmpty(), + decimals = amount?.decimals ?: 0, + canBeLower = isFeeApproximate, + ) +} + private fun getButtonData( uiState: SendUiState, currentState: SendUiCurrentScreen, @@ -245,11 +251,11 @@ private fun getButtonData( private fun isButtonEnabled(currentState: SendUiCurrentScreen, uiState: SendUiState): Boolean { return when (currentState.type) { - SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled + SendUiStateType.Amount -> uiState.amountState.isPrimaryButtonEnabled SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled SendUiStateType.Send -> uiState.sendState?.isPrimaryButtonEnabled - SendUiStateType.EditAmount -> uiState.editAmountState?.isPrimaryButtonEnabled + SendUiStateType.EditAmount -> uiState.editAmountState.isPrimaryButtonEnabled SendUiStateType.EditRecipient -> uiState.editRecipientState?.isPrimaryButtonEnabled SendUiStateType.EditFee -> uiState.editFeeState?.isPrimaryButtonEnabled else -> true diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt index f7aacce75a..712f915547 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt @@ -11,11 +11,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.ui.amountScreen.utils.getCryptoReference import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.rows.SelectorRowItem +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.parseToBigDecimal @@ -52,7 +52,14 @@ internal fun SendSpeedSelectorItem( iconRes = iconRes, onSelect = onSelect, modifier = modifier, - preDot = getCryptoReference(amount, state.isFeeApproximate), + preDot = stringReference( + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = amount?.value, + cryptoCurrency = amount?.currencySymbol.orEmpty(), + decimals = amount?.decimals ?: 0, + canBeLower = state.isFeeApproximate, + ), + ), postDot = if (state.isFeeConvertibleToFiat) { getFiatReference(amount?.value, state.rate, state.appCurrency) } else { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt index cf63542cee..75729e23b1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt @@ -141,7 +141,7 @@ private fun LazyListScope.memoField(memoField: SendTextField.RecipientMemo?, onM onValueChange = memoField.onValueChange, onPasteClick = onMemoChange, modifier = Modifier.padding(top = TangemTheme.dimens.spacing20), - labelStyle = TangemTheme.typography.caption2, + labelStyle = TangemTheme.typography.subtitle2, isError = memoField.isError, error = memoField.error, isReadOnly = !memoField.isEnabled, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt index c43df695b9..b396811b81 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt @@ -14,12 +14,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.tangem.common.ui.amountScreen.utils.getCryptoReference import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.rows.SelectorRowItem -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.features.send.impl.R import com.tangem.features.send.impl.presentation.state.SendStates @@ -61,7 +61,14 @@ internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, o SelectorRowItem( titleRes = title, iconRes = icon, - preDot = getCryptoReference(feeAmount, feeState.isFeeApproximate), + preDot = stringReference( + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = feeAmount?.value, + cryptoCurrency = feeAmount?.currencySymbol.orEmpty(), + decimals = feeAmount?.decimals ?: 0, + canBeLower = feeState.isFeeApproximate, + ), + ), postDot = if (feeState.isFeeConvertibleToFiat) { getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency) } else { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt index 4839b83690..313451a067 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt @@ -52,7 +52,7 @@ internal fun RecipientBlock( private fun AddressBlock(address: SendTextField.RecipientAddress) { Text( text = address.label.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.secondary, ) Row( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt index a57a241ebf..67de572bab 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt @@ -29,12 +29,14 @@ import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.repository.CurrencyChecksRepository -import com.tangem.domain.tokens.utils.convertToAmount import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.ValidateAddressError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -55,9 +57,8 @@ import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import timber.log.Timber import java.math.BigDecimal import java.util.Locale @@ -75,6 +76,8 @@ internal class SendViewModel @Inject constructor( private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, private val getFeeUseCase: GetFeeUseCase, private val sendTransactionUseCase: SendTransactionUseCase, @@ -161,7 +164,6 @@ internal class SendViewModel @Inject constructor( clickIntents = this, stateRouterProvider = Provider { stateRouter }, currentStateProvider = Provider { uiState.value }, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, feeStateFactory = feeStateFactory, ) @@ -195,7 +197,6 @@ internal class SendViewModel @Inject constructor( ) } - // todo convert to StateFlow val uiState: MutableStateFlow = MutableStateFlow( value = stateFactory.getInitialState(), ) @@ -856,7 +857,7 @@ internal class SendViewModel @Inject constructor( viewModelScope.launch { createTransactionUseCase( - amount = receivingAmount.convertToAmount(cryptoCurrency), + amount = receivingAmount.convertToSdkAmount(cryptoCurrency), fee = fee, memo = memo, destination = recipient, @@ -931,13 +932,38 @@ internal class SendViewModel @Inject constructor( private fun scheduleUpdates() { coroutineScope.launch { - // we should update network to find pending tx after 1 sec - fetchPendingTransactionsUseCase(userWallet.walletId, setOf(cryptoCurrency.network)) - // we should update network for new balance - updateDelayedCurrencyStatusUseCase( - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - delayMillis = BALANCE_UPDATE_DELAY, + listOf( + // we should update network to find pending tx after 1 sec + async { + fetchPendingTransactionsUseCase(userWallet.walletId, setOf(cryptoCurrency.network)) + }, + // we should update tx history and network for new balance + async { + updateTxHistory() + }, + async { + updateDelayedCurrencyStatusUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + delayMillis = BALANCE_UPDATE_DELAY, + refresh = true, + ) + }, + ).awaitAll() + } + } + + private suspend fun updateTxHistory() { + delay(BALANCE_UPDATE_DELAY) + val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, + ) + + txHistoryItemsCountEither.onRight { + getTxHistoryItemsUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, refresh = true, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt index c58332a870..ad93ecd3a3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/FeeState.kt @@ -1,9 +1,11 @@ package com.tangem.features.staking.impl.presentation.state +import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.appcurrency.model.AppCurrency import java.math.BigDecimal +@Immutable sealed class FeeState { data class Content( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt index dcea12e0ed..ef7e4e8fec 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt @@ -1,6 +1,8 @@ package com.tangem.features.staking.impl.presentation.state +import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield import kotlinx.collections.immutable.ImmutableList @@ -11,19 +13,24 @@ sealed class InnerYieldBalanceState { val rewardsCrypto: String, val rewardsFiat: String, val isRewardsToClaim: Boolean, - val balance: List, + val isRewardsClaimable: Boolean, + val balance: ImmutableList, ) : InnerYieldBalanceState() data object Empty : InnerYieldBalanceState() } +// TODO staking get rid of unstable types +@Immutable data class BalanceGroupedState( val items: ImmutableList, val footer: TextReference?, val title: TextReference, - val type: BalanceGroupType, + val type: BalanceType, + val isClickable: Boolean, ) +@Immutable data class BalanceState( val validator: Yield.Validator, val cryptoValue: String, @@ -33,10 +40,4 @@ data class BalanceState( val rawCurrencyId: String?, val unbondingPeriod: TextReference, val pendingActions: ImmutableList, -) - -enum class BalanceGroupType { - ACTIVE, - UNSTAKED, - UNKNOWN, -} \ No newline at end of file +) \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt index 967f681edb..c338f2c047 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt @@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.state 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.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.staking.impl.R @@ -23,7 +24,15 @@ internal sealed class StakingNotification(val config: NotificationConfig) { onCloseClick = onCloseClick, ), ) { - // TODO staking + data class StakedPositionNotFoundError(val message: String) : Error( + title = stringReference(message), + subtitle = stringReference(message), + ) + + data class Common(val subtitle: TextReference) : Error( + title = resourceReference(R.string.common_error), + subtitle = subtitle, + ) } sealed class Warning( @@ -41,14 +50,29 @@ internal sealed class StakingNotification(val config: NotificationConfig) { ), ) { data class EarnRewards( + val subtitleResourceId: Int, val currencyName: String, - val days: Int, ) : Warning( title = resourceReference(R.string.staking_notification_earn_rewards_title), subtitle = resourceReference( - R.string.staking_notification_earn_rewards_text, - wrappedList(currencyName, days), + subtitleResourceId, + wrappedList(currencyName), ), ) + + data class Unstake( + val cooldownPeriodDays: Int, + ) : Warning( + title = resourceReference(R.string.common_unstake), + subtitle = resourceReference( + R.string.staking_notification_unstake_text, + wrappedList(cooldownPeriodDays, cooldownPeriodDays), + ), + ) + + data class TransactionInProgress( + val title: TextReference, + val description: TextReference, + ) : Warning(title = title, subtitle = description) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index d3570e6c23..1bf70095ff 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 @@ -1,8 +1,13 @@ package com.tangem.features.staking.impl.presentation.state import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub +import com.tangem.features.staking.impl.presentation.state.transformers.SetButtonsStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetTitleTransformer import com.tangem.utils.transformer.Transformer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,20 +25,30 @@ internal class StakingStateController @Inject constructor() { val uiState: StateFlow get() = mutableUiState.asStateFlow() + private val buttonsTransformer = SetButtonsStateTransformer() + private val titleTransformer = SetTitleTransformer + fun update(function: (StakingUiState) -> StakingUiState) { mutableUiState.update(function = function) + mutableUiState.update(function = buttonsTransformer::transform) + mutableUiState.update(function = titleTransformer::transform) } fun update(transformer: Transformer) { mutableUiState.update(function = transformer::transform) + mutableUiState.update(function = buttonsTransformer::transform) + mutableUiState.update(function = titleTransformer::transform) } fun clear() { mutableUiState.update { getInitialState() } + mutableUiState.update(function = buttonsTransformer::transform) + mutableUiState.update(function = titleTransformer::transform) } private fun getInitialState(): StakingUiState { return StakingUiState( + title = TextReference.EMPTY, clickIntents = StakingClickIntentsStub, cryptoCurrencyName = "", currentStep = StakingStep.InitialInfo, @@ -44,7 +59,8 @@ internal class StakingStateController @Inject constructor() { isBalanceHidden = false, event = consumedEvent(), bottomSheetConfig = null, - routeType = RouteType.STAKE, + actionType = StakingActionCommonType.ENTER, + buttonsState = NavigationButtonsState.Empty, ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt index 2629f6405b..03cc906908 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt @@ -1,6 +1,7 @@ package com.tangem.features.staking.impl.presentation.state import com.tangem.common.routing.AppRouter +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType internal class StakingStateRouter( private val appRouter: AppRouter, @@ -14,12 +15,12 @@ internal class StakingStateRouter( fun onNextClick() { when (stateController.value.currentStep) { - StakingStep.InitialInfo -> when (stateController.value.routeType) { - RouteType.STAKE -> showAmount() - RouteType.OTHER, - RouteType.UNSTAKE, + StakingStep.InitialInfo -> when (stateController.value.actionType) { + StakingActionCommonType.ENTER -> showAmount() + StakingActionCommonType.PENDING_OTHER, + StakingActionCommonType.EXIT, -> showConfirmation() - RouteType.CLAIM -> showRewardsValidators() + StakingActionCommonType.PENDING_REWARDS -> showRewardsValidators() } StakingStep.RewardsValidators, StakingStep.Validators, @@ -32,10 +33,17 @@ internal class StakingStateRouter( } fun onPrevClick() { - when (stateController.uiState.value.currentStep) { + val uiState = stateController.uiState.value + when (uiState.currentStep) { StakingStep.InitialInfo -> onBackClick() StakingStep.Amount -> showInitial() - StakingStep.Confirmation -> showAmount() + StakingStep.Confirmation -> { + if (uiState.actionType != StakingActionCommonType.ENTER) { + showInitial() + } else { + showAmount() + } + } StakingStep.Validators -> showConfirmation() StakingStep.RewardsValidators -> showInitial() } 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 2ad72821a5..e9b4c44faa 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 @@ -2,11 +2,13 @@ package com.tangem.features.staking.impl.presentation.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.transformers.InfoType import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import kotlinx.collections.immutable.ImmutableList @@ -16,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList */ @Immutable internal data class StakingUiState( + val title: TextReference, val clickIntents: StakingClickIntents, val cryptoCurrencyName: String, val currentStep: StakingStep, @@ -25,7 +28,8 @@ internal data class StakingUiState( val confirmationState: StakingStates.ConfirmationState, val isBalanceHidden: Boolean, val bottomSheetConfig: TangemBottomSheetConfig?, - val routeType: RouteType, + val actionType: StakingActionCommonType, + val buttonsState: NavigationButtonsState, val event: StateEvent, ) { @@ -55,17 +59,6 @@ internal sealed class StakingStates { val isStakeMoreAvailable: Boolean, ) : InitialInfoState() - data class InitialInfoItems( - val available: String, - val onStake: String, - val aprRange: TextReference, - val unbondingPeriod: String, - val minimumRequirement: String, - val rewardClaiming: String, - val warmupPeriod: String, - val rewardSchedule: String, - ) - data class Empty( override val isPrimaryButtonEnabled: Boolean = false, ) : InitialInfoState() @@ -94,6 +87,8 @@ internal sealed class StakingStates { val notifications: ImmutableList, val footerText: String, val transactionDoneState: TransactionDoneState, + val pendingActionInProgress: PendingAction? = null, + val isApprovalNeeded: Boolean, ) : ConfirmationState() data class Empty( @@ -108,11 +103,4 @@ enum class StakingStep { Amount, Confirmation, Validators, -} - -enum class RouteType { - STAKE, - UNSTAKE, - CLAIM, - OTHER, } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt index 107207c36c..a1c35482de 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/RewardsValidatorStateConverter.kt @@ -44,7 +44,7 @@ internal class RewardsValidatorStateConverter( val validator = yield.validators.firstOrNull { it.address.contains(balance.validatorAddress.orEmpty(), ignoreCase = true) } - val cryptoValue = balance.amount.times(balance.pricePerShare) + val cryptoValue = balance.amount val fiatValue = cryptoCurrencyStatus.value.fiatRate?.times(cryptoValue) val unbondingPeriod = yield.metadata.cooldownPeriod.days validator?.toBalanceState( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 23141275d2..85b64c0645 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.state.converters +import com.tangem.common.extensions.isZero import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -10,7 +11,6 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.BalanceGroupType import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState @@ -35,7 +35,9 @@ internal class YieldBalancesConverter( val cryptoRewardsValue = yieldBalance.getRewardStakingBalance() val fiatRewardsValue = cryptoCurrencyStatus.value.fiatRate?.times(cryptoRewardsValue) val groupedBalances = getGroupedBalance(yieldBalance.balance) - + val isRewardsClaimable = yieldBalance.balance.items + .filter { it.type == BalanceType.REWARDS } + .any { it.pendingActions.isNotEmpty() } InnerYieldBalanceState.Data( rewardsCrypto = BigDecimalFormatter.formatCryptoAmount( cryptoAmount = cryptoRewardsValue, @@ -47,6 +49,7 @@ internal class YieldBalancesConverter( fiatCurrencySymbol = appCurrency.symbol, ), isRewardsToClaim = !cryptoRewardsValue.isNullOrZero(), + isRewardsClaimable = isRewardsClaimable, balance = groupedBalances, ) } else { @@ -59,83 +62,95 @@ internal class YieldBalancesConverter( .groupBy { it.type.toGroup() } .mapNotNull { item -> val (title, footer) = getGroupTitle(item.key) + val isClickable = getClickableType(item.key) title?.let { BalanceGroupedState( items = item.value.mapBalances().toPersistentList(), footer = footer, title = it, type = item.key, + isClickable = isClickable, ) } } + .filterNot { it.items.isEmpty() } + .toPersistentList() private fun List.mapBalances(): List { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val appCurrency = appCurrencyProvider() val cryptoCurrency = cryptoCurrencyStatus.currency - return this.mapNotNull { balance -> - val validator = yield.validators.firstOrNull { - balance.validatorAddress?.contains(it.address, ignoreCase = true) == true - } - val cryptoAmount = balance.amount * balance.pricePerShare - val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) - val unbondingPeriod = yield.metadata.cooldownPeriod.days - validator?.let { - BalanceState( - validator = validator, - cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), - cryptoDecimal = cryptoAmount, - cryptoAmount = stringReference( - BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = cryptoAmount, - cryptoCurrency = cryptoCurrency, + return this + .filterNot { it.amount.isZero() } + .mapNotNull { balance -> + val validator = yield.validators.firstOrNull { + balance.validatorAddress?.contains(it.address, ignoreCase = true) == true + } + val cryptoAmount = balance.amount + val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) + val unbondingPeriod = yield.metadata.cooldownPeriod.days + validator?.let { + BalanceState( + validator = validator, + cryptoValue = cryptoAmount.parseBigDecimal(cryptoCurrency.decimals), + cryptoDecimal = cryptoAmount, + cryptoAmount = stringReference( + BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = cryptoAmount, + cryptoCurrency = cryptoCurrency, + ), ), - ), - fiatAmount = stringReference( - BigDecimalFormatter.formatFiatAmount( - fiatAmount = fiatAmount, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, + fiatAmount = stringReference( + BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ), ), - ), - rawCurrencyId = balance.rawCurrencyId, - unbondingPeriod = pluralReference( - id = R.plurals.common_days, - count = unbondingPeriod, - formatArgs = wrappedList(unbondingPeriod), - ), - pendingActions = balance.pendingActions.toPersistentList(), - ) + rawCurrencyId = balance.rawCurrencyId, + unbondingPeriod = pluralReference( + id = R.plurals.common_days, + count = unbondingPeriod, + formatArgs = wrappedList(unbondingPeriod), + ), + pendingActions = balance.pendingActions.toPersistentList(), + ) + } } - } } private fun BalanceType.toGroup() = when (this) { - BalanceType.PREPARING, - BalanceType.STAKED, BalanceType.REWARDS, - BalanceType.AVAILABLE, - BalanceType.LOCKED, - -> BalanceGroupType.ACTIVE - BalanceType.UNSTAKING, - BalanceType.UNLOCKING, - BalanceType.UNSTAKED, - -> BalanceGroupType.UNSTAKED BalanceType.UNKNOWN, - -> BalanceGroupType.UNKNOWN + -> BalanceType.UNKNOWN + else -> this } - private fun getGroupTitle(type: BalanceGroupType) = when (type) { - BalanceGroupType.ACTIVE -> resourceReference( - R.string.staking_active, - ) to resourceReference( - R.string.staking_active_footer, - ) - BalanceGroupType.UNSTAKED -> resourceReference( - R.string.staking_unstaked, - ) to resourceReference( - R.string.staking_unstaked_footer, - ) - BalanceGroupType.UNKNOWN -> null to null + private fun getGroupTitle(type: BalanceType) = when (type) { + BalanceType.STAKED -> resourceReference(R.string.staking_active) to + resourceReference(R.string.staking_active_footer) + BalanceType.UNSTAKED -> resourceReference(R.string.staking_unstaked) to + resourceReference(R.string.staking_unstaked_footer) + BalanceType.UNSTAKING -> resourceReference(R.string.staking_unstaking) to null + BalanceType.AVAILABLE -> null to null + BalanceType.PREPARING -> null to null + BalanceType.REWARDS -> null to null + BalanceType.LOCKED -> null to null + BalanceType.UNLOCKING -> null to null + BalanceType.UNKNOWN -> null to null + } + + private fun getClickableType(type: BalanceType) = when (type) { + BalanceType.STAKED, + BalanceType.UNSTAKED, + -> true + BalanceType.AVAILABLE, + BalanceType.UNSTAKING, + BalanceType.PREPARING, + BalanceType.REWARDS, + BalanceType.LOCKED, + BalanceType.UNLOCKING, + BalanceType.UNKNOWN, + -> false } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt index 0994dd7cdb..807e584ca3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt @@ -5,6 +5,7 @@ import com.tangem.blockchain.common.AmountType.Coin import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.* import com.tangem.features.staking.impl.presentation.state.StakingNotification import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -81,10 +82,11 @@ internal object ConfirmationStatePreviewData { notifications = persistentListOf( StakingNotification.Warning.EarnRewards( currencyName = "Solana", - days = 2, + subtitleResourceId = R.string.staking_notification_earn_rewards_text_period_day, ), ), transactionDoneState = TransactionDoneState.Empty, pendingActions = persistentListOf(), + isApprovalNeeded = false, ) } \ No newline at end of file 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 cca20b1416..f31cd5f501 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 @@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.state.previewdata import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.* @@ -59,11 +60,13 @@ internal object InitialStakingStatePreview { rewardsFiat = "100 $", rewardsCrypto = "100 SOL", isRewardsToClaim = false, - balance = listOf( + isRewardsClaimable = false, + balance = persistentListOf( BalanceGroupedState( title = stringReference("Staked"), footer = null, - type = BalanceGroupType.ACTIVE, + type = BalanceType.STAKED, + isClickable = true, items = persistentListOf( BalanceState( cryptoValue = "100", diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt index b9a90741b4..c8e5a5d35a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -2,6 +2,7 @@ package com.tangem.features.staking.impl.presentation.state.stub import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.transformers.InfoType import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents @@ -11,10 +12,14 @@ object StakingClickIntentsStub : StakingClickIntents { override fun onBackClick() {} - override fun onNextClick(pendingActions: ImmutableList) {} + override fun onNextClick(actionType: StakingActionCommonType?, pendingActions: ImmutableList) {} + + override fun onActionClick(pendingAction: PendingAction?) {} override fun onPrevClick() {} + override fun onInitialInfoBannerClick() {} + override fun onInfoClick(infoType: InfoType) {} override fun onAmountValueChange(value: String) {} @@ -33,7 +38,9 @@ object StakingClickIntentsStub : StakingClickIntents { override fun openRewardsValidators() {} - override fun selectRewardValidator(rewardValue: String) {} + override fun showApprovalBottomSheet() {} + + override fun onApprovalClick() {} override fun onExploreClick() {} diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt new file mode 100644 index 0000000000..69278afeb6 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt @@ -0,0 +1,36 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class AddStakingErrorTransformer( + private val error: StakingError, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmationState = + prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState + + return prevState.copy( + confirmationState = confirmationState.copy( + notifications = (confirmationState.notifications + convertToNotification(error)).toPersistentList(), + feeState = FeeState.Error, + ), + ) + } + + private fun convertToNotification(error: StakingError): StakingNotification { + return when (error) { + is StakingError.StakedPositionNotFoundError -> StakingNotification.Error.StakedPositionNotFoundError( + message = error.toString(), + ) + // TODO staking + else -> StakingNotification.Error.Common( + subtitle = stringReference(error.toString()), + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6b4515a6ba --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -0,0 +1,249 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class SetButtonsStateTransformer : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + + val buttonsState = if (prevState.isButtonsVisible()) { + NavigationButtonsState.Data( + primaryButton = getPrimaryButton(prevState), + prevButton = getPrevButton(prevState), + secondaryButton = getSecondaryButton(prevState), + extraButtons = getExtraButtons(prevState), + txUrl = (confirmState?.transactionDoneState as? TransactionDoneState.Content)?.txUrl, + ) + } else { + NavigationButtonsState.Empty + } + + return prevState.copy(buttonsState = buttonsState) + } + + private fun getPrimaryButton(prevState: StakingUiState): NavigationButton { + val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val innerConfirmState = confirmState?.innerState + + val isPrimaryInProgress = + confirmState?.pendingActions?.getPrimaryAction() == confirmState?.pendingActionInProgress + val isConfirmation = prevState.currentStep == StakingStep.Confirmation + val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS + val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED + + val isIconVisible = isConfirmation && !isCompleted + val isShowProgress = isInProgress && isPrimaryInProgress + return NavigationButton( + textReference = prevState.getButtonText(), + iconRes = R.drawable.ic_tangem_24, + isSecondary = false, + isIconVisible = isIconVisible, + showProgress = isShowProgress, + isEnabled = prevState.isButtonEnabled(), + onClick = { prevState.onPrimaryClick() }, + ) + } + + private fun getSecondaryButton(prevState: StakingUiState): NavigationButton? { + val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val innerConfirmState = confirmState?.innerState + + val isConfirmation = prevState.currentStep == StakingStep.Confirmation + val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS + val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED + + return confirmState?.pendingActions?.getSecondaryAction()?.let { pendingAction -> + val isSecondaryInProgress = pendingAction == confirmState.pendingActionInProgress + val isShowProgress = isInProgress && isSecondaryInProgress + NavigationButton( + textReference = getPendingActionTitle(pendingAction.type), + iconRes = R.drawable.ic_tangem_24, + isSecondary = true, + isIconVisible = true, + showProgress = isShowProgress, + isEnabled = prevState.isButtonEnabled(), + onClick = { prevState.clickIntents.onActionClick(pendingAction) }, + ).takeIf { isConfirmation && !isCompleted } + } + } + + private fun getPrevButton(prevState: StakingUiState): NavigationButton? { + return NavigationButton( + textReference = TextReference.EMPTY, + iconRes = R.drawable.ic_back_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = prevState.clickIntents::onPrevClick, + ).takeIf { prevState.currentStep.isPrevButtonVisible() } + } + + private fun getExtraButtons(prevState: StakingUiState): ImmutableList { + return persistentListOf( + NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_web_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = prevState.clickIntents::onExploreClick, + ), + NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_share_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = prevState.clickIntents::onShareClick, + ), + ) + } + + private fun List.getPrimaryAction(): PendingAction? = getOrNull(0) + + private fun List.getSecondaryAction(): PendingAction? = getOrNull(1) + + private fun StakingUiState.isButtonsVisible(): Boolean = when (currentStep) { + StakingStep.InitialInfo -> isStakeMoreAvailable() + StakingStep.RewardsValidators -> false + else -> true + } + + private fun StakingUiState.getButtonText(): TextReference { + return when (currentStep) { + StakingStep.InitialInfo -> { + val initialState = initialInfoState as? StakingStates.InitialInfoState.Data + if (initialState?.yieldBalance is InnerYieldBalanceState.Data) { + resourceReference(R.string.staking_stake_more) + } else { + resourceReference(R.string.common_stake) + } + } + + StakingStep.Confirmation -> getConfirmationButtonText() + StakingStep.Validators -> resourceReference(R.string.common_continue) + StakingStep.Amount, + StakingStep.RewardsValidators, + -> resourceReference(R.string.common_next) + } + } + + private fun StakingUiState.getConfirmationButtonText(): TextReference { + return if (confirmationState is StakingStates.ConfirmationState.Data) { + if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { + resourceReference(R.string.common_close) + } else { + when (actionType) { + StakingActionCommonType.ENTER -> { + if (confirmationState.isApprovalNeeded) { + resourceReference(R.string.give_permission_title) + } else { + resourceReference(R.string.common_stake) + } + } + StakingActionCommonType.EXIT -> resourceReference(R.string.common_unstake) + StakingActionCommonType.PENDING_OTHER, + StakingActionCommonType.PENDING_REWARDS, + -> getPendingActionTitle(confirmationState.pendingActions.firstOrNull()?.type) + } + } + } else { + resourceReference(R.string.common_close) + } + } + + private fun StakingUiState.onPrimaryClick() { + when (currentStep) { + StakingStep.InitialInfo -> { + val actionType = StakingActionCommonType.ENTER.takeIf { isStakeMoreAvailable() } + clickIntents.onAmountValueChange("") // reset amount state + clickIntents.onNextClick(actionType) + } + StakingStep.Validators, + StakingStep.Amount, + -> clickIntents.onNextClick() + StakingStep.Confirmation -> onConfirmationClick() + StakingStep.RewardsValidators -> Unit + } + } + + private fun StakingUiState.onConfirmationClick() { + if (confirmationState is StakingStates.ConfirmationState.Data) { + if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { + clickIntents.onBackClick() + } else { + val isEnterAction = actionType == StakingActionCommonType.ENTER + val isApproveNeeded = confirmationState.isApprovalNeeded + + if (isEnterAction && isApproveNeeded) { + clickIntents.showApprovalBottomSheet() + } else { + clickIntents.onActionClick(confirmationState.pendingActions.firstOrNull()) + } + } + } else { + clickIntents.onBackClick() + } + } + + private fun StakingStep.isPrevButtonVisible(): Boolean = when (this) { + StakingStep.InitialInfo, + StakingStep.RewardsValidators, + StakingStep.Confirmation, + StakingStep.Validators, + -> false + StakingStep.Amount, + -> true + } + + private fun StakingUiState.isButtonEnabled(): Boolean { + return when (currentStep) { + StakingStep.InitialInfo -> initialInfoState.isPrimaryButtonEnabled + StakingStep.Amount -> amountState.isPrimaryButtonEnabled + StakingStep.Confirmation -> confirmationState.isPrimaryButtonEnabled + StakingStep.RewardsValidators -> rewardsValidatorsState.isPrimaryButtonEnabled + StakingStep.Validators -> true + } + } + + @Suppress("CyclomaticComplexMethod") + private fun getPendingActionTitle(type: StakingActionType?): TextReference = when (type) { + StakingActionType.CLAIM_REWARDS -> resourceReference(R.string.common_claim_rewards) + StakingActionType.RESTAKE_REWARDS -> resourceReference(R.string.staking_restake_rewards) + StakingActionType.WITHDRAW -> resourceReference(R.string.staking_withdraw) + StakingActionType.RESTAKE -> resourceReference(R.string.staking_restake) + StakingActionType.CLAIM_UNSTAKED -> resourceReference(R.string.staking_claim_unstaked) + StakingActionType.UNLOCK_LOCKED -> resourceReference(R.string.staking_unlocked_locked) + StakingActionType.STAKE_LOCKED -> resourceReference(R.string.staking_stake_locked) + StakingActionType.VOTE -> resourceReference(R.string.staking_vote) + StakingActionType.REVOKE -> resourceReference(R.string.staking_revoke) + StakingActionType.VOTE_LOCKED -> resourceReference(R.string.staking_vote_locked) + StakingActionType.REVOTE -> resourceReference(R.string.staking_revote) + StakingActionType.REBOND -> resourceReference(R.string.staking_rebond) + StakingActionType.MIGRATE -> resourceReference(R.string.staking_migrate) + StakingActionType.STAKE -> resourceReference(R.string.common_stake) + StakingActionType.UNSTAKE -> resourceReference(R.string.common_unstake) + StakingActionType.UNKNOWN -> TextReference.EMPTY + null -> TextReference.EMPTY + } + + private fun StakingUiState.isStakeMoreAvailable(): Boolean { + val initialState = initialInfoState as? StakingStates.InitialInfoState.Data + return initialState?.isStakeMoreAvailable == true || initialState?.yieldBalance is InnerYieldBalanceState.Empty + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt index f2dc22698e..ca470397b6 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt @@ -16,7 +16,7 @@ import kotlinx.collections.immutable.ImmutableList internal class SetConfirmationStateAssentTransformer( private val appCurrencyProvider: Provider, - private val cryptoCurrencyStatusProvider: Provider, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, private val stakingGasEstimate: StakingGasEstimate, private val pendingActionList: ImmutableList, ) : Transformer { @@ -31,6 +31,7 @@ internal class SetConfirmationStateAssentTransformer( gasEstimate: StakingGasEstimate, ): StakingStates.ConfirmationState { if (this is StakingStates.ConfirmationState.Data) { + val isFeeConvertibleToFiat = feeCryptoCurrencyStatus?.currency?.network?.hasFiatFeeRate == true return copy( innerState = InnerConfirmationStakingState.ASSENT, feeState = FeeState.Content( @@ -41,14 +42,15 @@ internal class SetConfirmationStateAssentTransformer( decimals = gasEstimate.token.decimals, ), ), - rate = cryptoCurrencyStatusProvider().value.fiatRate, - isFeeConvertibleToFiat = cryptoCurrencyStatusProvider().currency.network.hasFiatFeeRate, + rate = feeCryptoCurrencyStatus?.value?.fiatRate, + isFeeConvertibleToFiat = isFeeConvertibleToFiat, appCurrency = appCurrencyProvider(), isFeeApproximate = false, ), validatorState = validatorState.copySealed(isClickable = true), pendingActions = pendingActionList, isPrimaryButtonEnabled = true, + isApprovalNeeded = false, ) } else { return this diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt index fa8af0041e..6b11ad41ab 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateCompletedTransformer.kt @@ -14,7 +14,7 @@ import com.tangem.utils.transformer.Transformer internal class SetConfirmationStateCompletedTransformer( private val appCurrencyProvider: Provider, - private val cryptoCurrencyStatusProvider: Provider, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, private val stakingGasEstimate: StakingGasEstimate, private val txUrl: String, ) : Transformer { @@ -29,6 +29,7 @@ internal class SetConfirmationStateCompletedTransformer( gasEstimate: StakingGasEstimate, ): StakingStates.ConfirmationState { if (this is StakingStates.ConfirmationState.Data) { + val isFeeConvertibleToFiat = feeCryptoCurrencyStatus?.currency?.network?.hasFiatFeeRate == true return copy( isPrimaryButtonEnabled = true, innerState = InnerConfirmationStakingState.COMPLETED, @@ -40,8 +41,8 @@ internal class SetConfirmationStateCompletedTransformer( decimals = gasEstimate.token.decimals, ), ), - rate = cryptoCurrencyStatusProvider().value.fiatRate, - isFeeConvertibleToFiat = cryptoCurrencyStatusProvider().currency.network.hasFiatFeeRate, + rate = feeCryptoCurrencyStatus?.value?.fiatRate, + isFeeConvertibleToFiat = isFeeConvertibleToFiat, appCurrency = appCurrencyProvider(), isFeeApproximate = false, ), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt index e17aad84b4..817ff94491 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt @@ -1,11 +1,14 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.domain.staking.model.stakekit.PendingAction 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.StakingUiState import com.tangem.utils.transformer.Transformer -internal class SetConfirmationStateInProgressTransformer : Transformer { +internal class SetConfirmationStateInProgressTransformer( + private val pendingAction: PendingAction?, +) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( @@ -19,6 +22,7 @@ internal class SetConfirmationStateInProgressTransformer : Transformer { + return persistentListOf( + if (prevState.actionType == StakingActionCommonType.EXIT) { + StakingNotification.Warning.Unstake( + cooldownPeriodDays = yield.metadata.cooldownPeriod.days, + ) + } else { + StakingNotification.Warning.EarnRewards( + currencyName = yield.token.name, + subtitleResourceId = getEarnRewardsPeriod(yield.metadata.rewardSchedule), + ) + }, + ) + } + + private fun getEarnRewardsPeriod(rewardSchedule: Yield.Metadata.RewardSchedule): Int { + return when (rewardSchedule) { + Yield.Metadata.RewardSchedule.BLOCK, + Yield.Metadata.RewardSchedule.DAY, + Yield.Metadata.RewardSchedule.ERA, + Yield.Metadata.RewardSchedule.EPOCH, + -> R.string.staking_notification_earn_rewards_text_period_day + + Yield.Metadata.RewardSchedule.HOUR, + -> R.string.staking_notification_earn_rewards_text_period_hour + + Yield.Metadata.RewardSchedule.WEEK, + -> R.string.staking_notification_earn_rewards_text_period_week + + Yield.Metadata.RewardSchedule.MONTH, + -> R.string.staking_notification_earn_rewards_text_period_month + + else + -> R.string.staking_notification_earn_rewards_text_period_day + } + } } \ No newline at end of file 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 5dba67759a..8d8a69e019 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 @@ -5,36 +5,35 @@ import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.list.RoundedListWithDividersItemData -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.serialization.SerializedBigDecimal import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.* 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.TransactionDoneState import com.tangem.features.staking.impl.presentation.state.ValidatorState import com.tangem.features.staking.impl.presentation.state.converters.RewardsValidatorStateConverter import com.tangem.features.staking.impl.presentation.state.converters.YieldBalancesConverter -import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import com.tangem.utils.Provider -import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal +@Suppress("LongParameterList") internal class SetInitialDataStateTransformer( private val clickIntents: StakingClickIntents, private val yield: Yield, private val isStakeMoreAvailable: Boolean, + private val isApprovalNeeded: Boolean, private val cryptoCurrencyStatusProvider: Provider, private val userWalletProvider: Provider, private val appCurrencyProvider: Provider, @@ -62,6 +61,11 @@ internal class SetInitialDataStateTransformer( override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( + title = TextReference.Res( + R.string.staking_title_stake, + wrappedList(cryptoCurrencyStatusProvider().currency.name), + ), + cryptoCurrencyName = cryptoCurrencyStatusProvider.invoke().currency.name, clickIntents = clickIntents, currentStep = StakingStep.InitialInfo, initialInfoState = createInitialInfoState(), @@ -75,7 +79,7 @@ internal class SetInitialDataStateTransformer( private fun createInitialInfoState(): StakingStates.InitialInfoState.Data { return StakingStates.InitialInfoState.Data( isPrimaryButtonEnabled = true, - aprRange = getAprRange(), + aprRange = getAprRange(yield.validators), infoItems = getInfoItems(), onInfoClick = clickIntents::onInfoClick, yieldBalance = yieldBalancesConverter.convert(Unit), @@ -85,72 +89,114 @@ internal class SetInitialDataStateTransformer( private fun getInfoItems(): PersistentList { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() - val yieldBalance = cryptoCurrencyStatus.value.yieldBalance - return persistentListOf( - RoundedListWithDividersItemData( - id = R.string.staking_details_available, - startText = TextReference.Res(R.string.staking_details_available), - endText = TextReference.Str( - value = BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = cryptoCurrencyStatus.value.amount, - cryptoCurrency = cryptoCurrencyStatus.currency.symbol, - decimals = cryptoCurrencyStatus.currency.decimals, - ), + return listOfNotNull( + createAnnualPercentageRateItem(yield.validators), + createAvailableItem(cryptoCurrencyStatus), + createUnbondingPeriodItem(yield.metadata.cooldownPeriod.days), + createMinimumRequirementItem( + cryptoCurrencyStatus = cryptoCurrencyStatus, + minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum, + ), + createRewardClaimingItem(yield.metadata.rewardClaiming), + createWarmupPeriodItem(yield.metadata.warmupPeriod.days), + createRewardScheduleItem(yield.metadata.rewardSchedule), + ).toPersistentList() + } + + private fun createAnnualPercentageRateItem(validators: List): RoundedListWithDividersItemData { + return RoundedListWithDividersItemData( + id = R.string.staking_details_annual_percentage_rate, + startText = TextReference.Res(R.string.staking_details_annual_percentage_rate), + endText = getAprRange(validators), + iconClick = { clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) }, + ) + } + + private fun createAvailableItem(cryptoCurrencyStatus: CryptoCurrencyStatus): RoundedListWithDividersItemData { + return RoundedListWithDividersItemData( + id = R.string.staking_details_available, + startText = TextReference.Res(R.string.staking_details_available), + endText = TextReference.Str( + value = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = cryptoCurrencyStatus.value.amount, + cryptoCurrency = cryptoCurrencyStatus.currency.symbol, + decimals = cryptoCurrencyStatus.currency.decimals, ), ), - RoundedListWithDividersItemData( - id = R.string.staking_details_annual_percentage_rate, - startText = TextReference.Res(R.string.staking_details_annual_percentage_rate), - endText = getAprRange(), - iconClick = { clickIntents.onInfoClick(InfoType.APY) }, + ) + } + + private fun createUnbondingPeriodItem(cooldownPeriodDays: Int): RoundedListWithDividersItemData { + return RoundedListWithDividersItemData( + id = R.string.staking_details_unbonding_period, + startText = TextReference.Res(R.string.staking_details_unbonding_period), + endText = pluralReference( + id = R.plurals.common_days, + count = cooldownPeriodDays, + formatArgs = wrappedList(cooldownPeriodDays), ), - RoundedListWithDividersItemData( - id = 0, // todo remove in merge - startText = TextReference.Res(0), // todo remove in merge - endText = TextReference.Str( - value = BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = (yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero(), - cryptoCurrency = cryptoCurrencyStatus.currency.symbol, - decimals = cryptoCurrencyStatus.currency.decimals, - ), + iconClick = { clickIntents.onInfoClick(InfoType.UNBONDING_PERIOD) }, + ) + } + + private fun createMinimumRequirementItem( + cryptoCurrencyStatus: CryptoCurrencyStatus, + minimumCryptoAmount: SerializedBigDecimal?, + ): RoundedListWithDividersItemData? { + minimumCryptoAmount ?: return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_minimum_requirement, + startText = TextReference.Res(R.string.staking_details_minimum_requirement), + endText = TextReference.Str( + value = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = minimumCryptoAmount, + cryptoCurrency = cryptoCurrencyStatus.currency.symbol, + decimals = cryptoCurrencyStatus.currency.decimals, ), ), - RoundedListWithDividersItemData( - id = R.string.staking_details_unbonding_period, - startText = TextReference.Res(R.string.staking_details_unbonding_period), - endText = TextReference.Str(yield.metadata.cooldownPeriod.days.toString()), - iconClick = { clickIntents.onInfoClick(InfoType.UNBOUNDING_PERIOD) }, - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_minimum_requirement, - startText = TextReference.Res(R.string.staking_details_minimum_requirement), - endText = TextReference.Str( - value = BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = yield.args.enter.args[KEY_AMOUNT]?.minimum?.toBigDecimal(), - cryptoCurrency = cryptoCurrencyStatus.currency.symbol, - decimals = cryptoCurrencyStatus.currency.decimals, - ), - ), - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_reward_claiming, - startText = TextReference.Res(R.string.staking_details_reward_claiming), - endText = TextReference.Str(yield.metadata.rewardClaiming), - iconClick = { clickIntents.onInfoClick(InfoType.REWARD_CLAIMING) }, - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_warmup_period, - startText = TextReference.Res(R.string.staking_details_warmup_period), - endText = TextReference.Str(yield.metadata.warmupPeriod.days.toString()), - iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) }, - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_reward_schedule, - startText = TextReference.Res(R.string.staking_details_reward_schedule), - endText = TextReference.Str(yield.metadata.rewardSchedule), - iconClick = { clickIntents.onInfoClick(InfoType.REWARD_SCHEDULE) }, + ) + } + + private fun createRewardClaimingItem( + rewardClaiming: Yield.Metadata.RewardClaiming, + ): RoundedListWithDividersItemData? { + val endTextId = rewardClaimingResources[rewardClaiming] ?: return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_reward_claiming, + startText = TextReference.Res(R.string.staking_details_reward_claiming), + endText = TextReference.Res(endTextId), + iconClick = { clickIntents.onInfoClick(InfoType.REWARD_CLAIMING) }, + ) + } + + private fun createWarmupPeriodItem(warmupPeriodDays: Int): RoundedListWithDividersItemData? { + if (warmupPeriodDays == 0) return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_warmup_period, + startText = TextReference.Res(R.string.staking_details_warmup_period), + endText = pluralReference( + id = R.plurals.common_days, + count = warmupPeriodDays, + formatArgs = wrappedList(warmupPeriodDays), ), + iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) }, + ) + } + + private fun createRewardScheduleItem( + rewardSchedule: Yield.Metadata.RewardSchedule, + ): RoundedListWithDividersItemData? { + val endTextId = rewardScheduleResources[rewardSchedule] ?: return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_reward_schedule, + startText = TextReference.Res(R.string.staking_details_reward_schedule), + endText = TextReference.Res(endTextId), + iconClick = { clickIntents.onInfoClick(InfoType.REWARD_SCHEDULE) }, ) } @@ -159,17 +205,22 @@ internal class SetInitialDataStateTransformer( } private fun createInitialConfirmationState(): StakingStates.ConfirmationState { - return ConfirmationStatePreviewData.assentStakingState.copy( - validatorState = ValidatorState.Content( - isClickable = true, - chosenValidator = yield.validators.first(), - availableValidators = yield.validators, - ), + return StakingStates.ConfirmationState.Data( + isPrimaryButtonEnabled = false, + innerState = InnerConfirmationStakingState.ASSENT, + feeState = FeeState.Loading, + validatorState = ValidatorState.Loading, + notifications = persistentListOf(), + footerText = "", + transactionDoneState = TransactionDoneState.Empty, + pendingActions = persistentListOf(), + pendingActionInProgress = null, + isApprovalNeeded = isApprovalNeeded, ) } - private fun getAprRange(): TextReference { - val aprValues = yield.validators.mapNotNull { it.apr } + private fun getAprRange(validators: List): TextReference { + val aprValues = validators.mapNotNull { it.apr } val minApr = aprValues.min() val maxApr = aprValues.max() @@ -191,6 +242,22 @@ internal class SetInitialDataStateTransformer( companion object { private val EQUALITY_THRESHOLD = BigDecimal(1E-10) - private const val KEY_AMOUNT = "amount" + + private val rewardScheduleResources = mapOf( + Yield.Metadata.RewardSchedule.BLOCK to R.string.staking_reward_schedule_each_day, + Yield.Metadata.RewardSchedule.WEEK to R.string.staking_reward_schedule_week, + Yield.Metadata.RewardSchedule.HOUR to R.string.staking_reward_schedule_hour, + Yield.Metadata.RewardSchedule.DAY to R.string.staking_reward_schedule_each_day, + Yield.Metadata.RewardSchedule.MONTH to R.string.staking_reward_schedule_month, + Yield.Metadata.RewardSchedule.ERA to R.string.staking_reward_schedule_era, + Yield.Metadata.RewardSchedule.EPOCH to R.string.staking_reward_schedule_epoch, + Yield.Metadata.RewardSchedule.UNKNOWN to null, + ) + + private val rewardClaimingResources = mapOf( + Yield.Metadata.RewardClaiming.MANUAL to R.string.staking_reward_claiming_manual, + Yield.Metadata.RewardClaiming.AUTO to R.string.staking_reward_claiming_auto, + Yield.Metadata.RewardSchedule.UNKNOWN to null, + ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt new file mode 100644 index 0000000000..ffb66a5ee1 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt @@ -0,0 +1,41 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.utils.transformer.Transformer + +internal object SetTitleTransformer : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val actionType = prevState.actionType + val currentStep = prevState.currentStep + + val title = when { + currentStep == StakingStep.Amount -> { + resourceReference(R.string.send_amount_label) + } + + currentStep == StakingStep.Validators -> { + resourceReference(R.string.staking_validators) + } + + actionType == StakingActionCommonType.EXIT && currentStep != StakingStep.InitialInfo -> { + resourceReference( + R.string.staking_title_unstake, + wrappedList(prevState.cryptoCurrencyName), + ) + } + else -> { + resourceReference( + R.string.staking_title_stake, + wrappedList(prevState.cryptoCurrencyName), + ) + } + } + + return prevState.copy(title = title) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt index 05db6fb7ba..06f89bdd94 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt @@ -18,11 +18,11 @@ internal class ShowInfoBottomSheetStateTransformer( onDismissRequest = onDismiss, isShow = true, content = when (infoType) { - InfoType.APY -> StakingInfoBottomSheetConfig( + InfoType.ANNUAL_PERCENTAGE_RATE -> StakingInfoBottomSheetConfig( title = resourceReference(R.string.staking_details_annual_percentage_rate), text = resourceReference(R.string.staking_details_annual_percentage_rate_info), ) - InfoType.UNBOUNDING_PERIOD -> StakingInfoBottomSheetConfig( + InfoType.UNBONDING_PERIOD -> StakingInfoBottomSheetConfig( title = resourceReference(R.string.staking_details_unbonding_period), text = resourceReference(R.string.staking_details_unbonding_period_info), ) @@ -45,8 +45,8 @@ internal class ShowInfoBottomSheetStateTransformer( } enum class InfoType { - APY, - UNBOUNDING_PERIOD, + ANNUAL_PERCENTAGE_RATE, + UNBONDING_PERIOD, REWARD_CLAIMING, WARMUP_PERIOD, REWARD_SCHEDULE, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt index 110de484fc..b87f8a0251 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt @@ -1,18 +1,31 @@ package com.tangem.features.staking.impl.presentation.state.transformers.amount import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer internal class AmountChangeStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val yield: Yield, private val value: String, ) : Transformer { + private val amountRequirementStateTransformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus, + yield, + value, + ) + override fun transform(prevState: StakingUiState): StakingUiState { + val updatedAmountState = AmountFieldChangeTransformer( + cryptoCurrencyStatus, + value, + ).transform(prevState.amountState) + return prevState.copy( - amountState = AmountFieldChangeTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState), + amountState = amountRequirementStateTransformer.transform(updatedAmountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt index 3d7dc9125d..ae53eab63e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt @@ -1,16 +1,29 @@ package com.tangem.features.staking.impl.presentation.state.transformers.amount import com.tangem.common.ui.amountScreen.converters.field.AmountFieldMaxAmountTransformer +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer internal class AmountMaxValueStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val yield: Yield, ) : Transformer { + + private val amountRequirementStateTransformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + yield = yield, + value = cryptoCurrencyStatus.value.amount + ?.parseBigDecimal(cryptoCurrencyStatus.currency.decimals) + .orEmpty(), + ) + override fun transform(prevState: StakingUiState): StakingUiState { + val updatedAmountState = AmountFieldMaxAmountTransformer(cryptoCurrencyStatus).transform(prevState.amountState) return prevState.copy( - amountState = AmountFieldMaxAmountTransformer(cryptoCurrencyStatus).transform(prevState.amountState), + amountState = amountRequirementStateTransformer.transform(updatedAmountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt new file mode 100644 index 0000000000..f16597ae54 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -0,0 +1,65 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.amount + +import com.tangem.common.extensions.isZero +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.staking.model.stakekit.AddressArgument +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.R +import com.tangem.utils.transformer.Transformer + +internal class AmountRequirementStateTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val yield: Yield, + private val value: String, +) : Transformer { + override fun transform(prevState: AmountState): AmountState { + val amountRequirements = yield.args.enter.args[Yield.Args.ArgType.AMOUNT] + + return if (prevState !is AmountState.Data || amountRequirements == null) { + prevState + } else { + updateWithError(prevState, amountRequirements) + } + } + + private fun updateWithError(prevState: AmountState.Data, amountRequirements: AddressArgument): AmountState { + val isRequirementError = isRequirementError(prevState, amountRequirements) + return if (isRequirementError) { + prevState.copy( + amountTextField = prevState.amountTextField.copy( + isError = true, + error = resourceReference( + R.string.staking_amount_requirement_error, + wrappedList( + BigDecimalFormatter.formatCryptoAmount( + amountRequirements.minimum, + cryptoCurrencyStatus.currency.symbol, + cryptoCurrencyStatus.currency.decimals, + ), + ), + ), + ), + ) + } else { + prevState + } + } + + private fun isRequirementError(prevState: AmountState.Data, amountRequirements: AddressArgument): Boolean { + val amountDecimal = value.parseToBigDecimal(cryptoCurrencyStatus.currency.decimals) + + val isAlreadyErrorState = prevState.amountTextField.isError + val isAmountRequired = amountRequirements.required + val isAmountZero = amountDecimal.isZero() + val isExceedsRequirements = + amountRequirements.maximum?.compareTo(amountDecimal) == -1 || + amountRequirements.minimum?.compareTo(amountDecimal) == 1 + + return !isAmountZero && isAmountRequired && isExceedsRequirements && !isAlreadyErrorState + } +} \ 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 new file mode 100644 index 0000000000..085061c2b8 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt @@ -0,0 +1,34 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.approval + +import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer + +internal class SetApprovalBottomSheetInProgressTransformer( + private val onDismiss: () -> Unit, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val approvalBottomSheetConfig = prevState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig + return prevState.copy( + bottomSheetConfig = prevState.bottomSheetConfig?.copy( + onDismissRequest = onDismiss, + isShow = true, + content = approvalBottomSheetConfig?.let { config -> + config.copy( + data = config.data.copy( + approveButton = config.data.approveButton.copy( + enabled = false, + loading = true, + ), + cancelButton = config.data.cancelButton.copy( + enabled = false, + ), + ), + onCancel = onDismiss, + ) + } as TangemBottomSheetConfigContent, + ), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalInProgressTransformer.kt new file mode 100644 index 0000000000..f9a5418f89 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalInProgressTransformer.kt @@ -0,0 +1,31 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.approval + +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.StakingNotification +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal object SetApprovalInProgressTransformer : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val state = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val notifications = state?.notifications?.toMutableList() ?: mutableListOf() + + notifications.add( + StakingNotification.Warning.TransactionInProgress( + title = resourceReference(R.string.warning_approval_in_progress_title), + description = resourceReference(R.string.warning_approval_in_progress_message), + ), + ) + + val updatedConfirmationState = state?.copy( + notifications = notifications.toPersistentList(), + ) ?: prevState.confirmationState + + return prevState.copy( + confirmationState = updatedConfirmationState, + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetConfirmationStateAssentApprovalTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetConfirmationStateAssentApprovalTransformer.kt new file mode 100644 index 0000000000..939c6877dc --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetConfirmationStateAssentApprovalTransformer.kt @@ -0,0 +1,46 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.approval + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.presentation.state.FeeState +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.StakingUiState +import com.tangem.utils.Provider +import com.tangem.utils.transformer.Transformer + +internal class SetConfirmationStateAssentApprovalTransformer( + private val appCurrencyProvider: Provider, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, + private val fee: TransactionFee, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + return prevState.copy( + confirmationState = prevState.confirmationState.copyWrapped(), + bottomSheetConfig = null, + ) + } + + private fun StakingStates.ConfirmationState.copyWrapped(): StakingStates.ConfirmationState { + return if (this is StakingStates.ConfirmationState.Data) { + val isFeeConvertibleToFiat = feeCryptoCurrencyStatus?.currency?.network?.hasFiatFeeRate == true + copy( + innerState = InnerConfirmationStakingState.ASSENT, + feeState = FeeState.Content( + fee = fee.normal, + rate = feeCryptoCurrencyStatus?.value?.fiatRate, + isFeeConvertibleToFiat = isFeeConvertibleToFiat, + appCurrency = appCurrencyProvider(), + isFeeApproximate = false, + ), + validatorState = validatorState.copySealed(isClickable = true), + isPrimaryButtonEnabled = true, + isApprovalNeeded = true, + ) + } else { + this + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt new file mode 100644 index 0000000000..4c1c1647f9 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt @@ -0,0 +1,81 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.approval + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.bottomsheet.permission.state.* +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.ValidatorState +import com.tangem.utils.Provider +import com.tangem.utils.transformer.Transformer + +internal class ShowApprovalBottomSheetTransformer( + private val appCurrencyProvider: Provider, + private val cryptoCurrencyStatusProvider: Provider, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, + private val onDismiss: () -> Unit, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val cryptoCurrency = cryptoCurrencyStatusProvider().currency + val cryptoCurrencyValue = cryptoCurrencyStatusProvider().value + + val amountState = prevState.amountState as? AmountState.Data ?: return prevState + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState + val validatorState = confirmationState.validatorState as? ValidatorState.Content ?: return prevState + val feeState = confirmationState.feeState as? FeeState.Content ?: return prevState + val fee = feeState.fee ?: return prevState + + val walletAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value.orEmpty() + val validatorAddress = validatorState.chosenValidator.address + val feeCryptoValue = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = fee.amount.value, + cryptoCurrency = fee.amount.currencySymbol, + decimals = fee.amount.decimals, + ) + val feeFiatValue = BigDecimalFormatter.formatFiatAmount( + fiatAmount = feeCryptoCurrencyStatus?.value?.fiatRate?.multiply(fee.amount.value), + fiatCurrencyCode = appCurrencyProvider().code, + fiatCurrencySymbol = appCurrencyProvider().symbol, + ) + return prevState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = GiveTxPermissionBottomSheetConfig( + data = GiveTxPermissionState.ReadyForRequest( + currency = cryptoCurrency.symbol, + amount = amountState.amountTextField.value, + approveType = ApproveType.LIMITED, + walletAddress = walletAddress, + spenderAddress = validatorAddress, + fee = resourceReference( + R.string.common_crypto_fiat_format, + wrappedList(feeCryptoValue, feeFiatValue), + ), + approveButton = ApprovePermissionButton( + enabled = true, + loading = false, + onClick = prevState.clickIntents::onApprovalClick, + ), + cancelButton = CancelPermissionButton( + enabled = true, + ), + subtitle = resourceReference( + id = R.string.give_permission_staking_subtitle, + formatArgs = wrappedList(cryptoCurrency.symbol), + ), + dialogText = resourceReference(R.string.give_permission_staking_footer), + ), + onCancel = onDismiss, + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt index 273108e5b5..eba4aae409 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt @@ -11,13 +11,17 @@ internal class ValidatorSelectChangeTransformer( ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState - val validatorState = confirmationState.validatorState as? ValidatorState.Content ?: return prevState + val validatorState = (confirmationState.validatorState as? ValidatorState.Content)?.copy( + chosenValidator = selectedValidator, + ) ?: ValidatorState.Content( + isClickable = false, + availableValidators = emptyList(), + chosenValidator = selectedValidator, + ) return prevState.copy( confirmationState = confirmationState.copy( - validatorState = validatorState.copy( - chosenValidator = selectedValidator, - ), + validatorState = validatorState, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt index 64a2f51b56..3e6723c8b2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt @@ -9,9 +9,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Modifier -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import com.tangem.core.ui.components.inputrow.InputRowImageInfo import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.* @@ -31,7 +28,7 @@ internal fun StakingClaimRewardsValidatorContent( if (state !is StakingStates.RewardsValidatorsState.Data) return Column( modifier = Modifier // Do not put fillMaxSize() in here - .background(TangemTheme.colors.background.tertiary) + .background(TangemTheme.colors.background.secondary) .padding(horizontal = TangemTheme.dimens.spacing12) .verticalScroll(rememberScrollState()), ) { @@ -41,19 +38,16 @@ internal fun StakingClaimRewardsValidatorContent( subtitle = stringReference(item.validator.name), caption = combinedReference( resourceReference(R.string.staking_details_apr), - annotatedReference( - buildAnnotatedString { - appendSpace() - withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { - append( - BigDecimalFormatter.formatPercent( - percent = item.validator.apr.orZero(), - useAbsoluteValue = true, - ), - ) - } - }, - ), + annotatedReference { + appendSpace() + appendColored( + text = BigDecimalFormatter.formatPercent( + percent = item.validator.apr.orZero(), + useAbsoluteValue = true, + ), + color = TangemTheme.colors.text.accent, + ) + }, ), infoTitle = item.fiatAmount, infoSubtitle = item.cryptoAmount, @@ -63,7 +57,7 @@ internal fun StakingClaimRewardsValidatorContent( .background(TangemTheme.colors.background.action) .clickable( onClick = { - clickIntents.selectRewardValidator(item.cryptoValue) + clickIntents.onActiveStake(item) }, ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt index 261d4437cf..ae5079b938 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt @@ -18,8 +18,8 @@ import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.components.transactions.TransactionDoneTitle import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.RouteType import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.TransactionDoneState import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData @@ -34,15 +34,14 @@ internal fun StakingConfirmationContent( amountState: AmountState, state: StakingStates.ConfirmationState, clickIntents: StakingClickIntents, - type: RouteType, + type: StakingActionCommonType, ) { if (state !is StakingStates.ConfirmationState.Data) return Column( modifier = Modifier - .fillMaxSize() - .background(TangemTheme.colors.background.tertiary) - .padding(TangemTheme.dimens.spacing16) + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), ) { @@ -62,7 +61,7 @@ internal fun StakingConfirmationContent( isEditingDisabled = true, onClick = {}, ) - if (type == RouteType.STAKE) { + if (type == StakingActionCommonType.ENTER) { ValidatorBlock(validatorState = state.validatorState, onClick = clickIntents::openValidators) } StakingFeeBlock(feeState = state.feeState) @@ -93,7 +92,7 @@ private fun Preview_StakingConfirmationContent() { amountState = AmountStatePreviewData.amountState, state = ConfirmationStatePreviewData.assentStakingState, clickIntents = StakingClickIntentsStub, - type = RouteType.STAKE, + type = StakingActionCommonType.ENTER, ) } } 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 859bc837c1..6174dcc79c 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 @@ -1,15 +1,15 @@ package com.tangem.features.staking.impl.presentation.ui import android.content.res.Configuration -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.key @@ -17,123 +17,125 @@ 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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.containers.FooterContainer import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.components.inputrow.InputRowImageInfo -import com.tangem.core.ui.components.list.RoundedListWithDividers +import com.tangem.core.ui.components.list.roundedListWithDividersItems import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState +import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState +import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.previewdata.InitialStakingStatePreview import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.StringsSigns.PLUS import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.ImmutableList +private const val BANNER_BLOCK_KEY = "BannerBlock" +private const val STAKING_REWARD_BLOCK_KEY = "StakingRewardBlock" +private const val ACTIVE_STAKING_BLOCK_KEY = "ActiveStakingBlock" + +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun StakingInitialInfoContent(state: StakingStates.InitialInfoState, clickIntents: StakingClickIntents) { if (state !is StakingStates.InitialInfoState.Data) return - Column( - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), - modifier = Modifier // Do not put fillMaxSize() in here - .background(TangemTheme.colors.background.tertiary) - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing16, - ), + LazyColumn( + modifier = Modifier + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16), ) { - AnimatedVisibility(state.yieldBalance == InnerYieldBalanceState.Empty) { - MetricsBlock(state) - } - RoundedListWithDividers(state.infoItems) - AnimatedContent(targetState = state.yieldBalance, label = "Rewards block visibility animation") { - if (it is InnerYieldBalanceState.Data) { - StakingRewardBlock( - rewardCrypto = it.rewardsCrypto, - rewardFiat = it.rewardsFiat, - isRewardsToClaim = it.isRewardsToClaim, - onRewardsClick = clickIntents::openRewardsValidators, - ) + if (state.yieldBalance == InnerYieldBalanceState.Empty) { + item(key = BANNER_BLOCK_KEY) { + Column( + modifier = Modifier.animateItemPlacement(), + ) { + BannerBlock(onClick = clickIntents::onInitialInfoBannerClick) + SpacerH12() + } } } - AnimatedContent(targetState = state.yieldBalance, label = "Rewards block visibility animation") { - if (it is InnerYieldBalanceState.Data) { - ActiveStakingBlock(it.balance, clickIntents::onActiveStake) + + this.roundedListWithDividersItems( + rows = state.infoItems, + footerContent = { SpacerH12() }, + ) + + if (state.yieldBalance is InnerYieldBalanceState.Data) { + item(key = STAKING_REWARD_BLOCK_KEY) { + Column(modifier = Modifier.animateItemPlacement()) { + StakingRewardBlock( + rewardCrypto = state.yieldBalance.rewardsCrypto, + rewardFiat = state.yieldBalance.rewardsFiat, + isRewardsToClaim = state.yieldBalance.isRewardsToClaim, + isRewardsClaimable = state.yieldBalance.isRewardsClaimable, + onRewardsClick = clickIntents::openRewardsValidators, + ) + SpacerH12() + } + } + } + + if (state.yieldBalance is InnerYieldBalanceState.Data) { + item(key = ACTIVE_STAKING_BLOCK_KEY) { + Column(modifier = Modifier.animateItemPlacement()) { + ActiveStakingBlock(state.yieldBalance.balance, clickIntents::onActiveStake) + SpacerH12() + } } } } } @Composable -private fun MetricsBlock(state: StakingStates.InitialInfoState.Data) { - Column( +private fun BannerBlock(onClick: () -> Unit) { + Box( modifier = Modifier - .background( - color = TangemTheme.colors.background.primary, - shape = RoundedCornerShape(TangemTheme.dimens.radius12), - ) - .padding(TangemTheme.dimens.spacing12) - .fillMaxWidth(), + .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(), + onClick = onClick, + ), ) { - Text( - text = stringResource(id = R.string.staking_details_metrics_block_header), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, + Image( + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillWidth, + painter = painterResource(R.drawable.img_staking_banner), + contentDescription = null, ) - Spacer(modifier = Modifier.height(TangemTheme.dimens.size8)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1F)) { - Text( - text = stringResource(id = R.string.staking_details_apr), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), - text = state.aprRange.resolveReference(), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.accent, - ) - } - Column(modifier = Modifier.weight(1F)) { - Row { - Text( - modifier = Modifier.padding(end = TangemTheme.dimens.spacing4), - text = stringResource(id = R.string.staking_details_market_rating), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - Icon( - modifier = Modifier - .size(TangemTheme.dimens.size16) - .align(Alignment.CenterVertically), - painter = painterResource(id = R.drawable.ic_alert_24), - contentDescription = null, - tint = TangemTheme.colors.text.tertiary, - ) + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(TangemTheme.dimens.spacing16), + text = buildAnnotatedString { + withStyle(SpanStyle(Brush.linearGradient(textGradientColors))) { + append(stringResource(R.string.staking_details_banner_text)) } - Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), - text = "1", // TODO staking - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.accent, - ) - } - } + }, + style = TangemTheme.typography.h2, + ) } } @@ -142,6 +144,7 @@ private fun StakingRewardBlock( rewardCrypto: String, rewardFiat: String, isRewardsToClaim: Boolean, + isRewardsClaimable: Boolean, onRewardsClick: () -> Unit, ) { val (text, textColor) = if (isRewardsToClaim) { @@ -160,7 +163,7 @@ private fun StakingRewardBlock( InputRowDefault( title = resourceReference(R.string.staking_rewards), text = text, - iconRes = R.drawable.ic_chevron_right_24, + iconRes = R.drawable.ic_chevron_right_24.takeIf { isRewardsToClaim && isRewardsClaimable }, textColor = textColor, modifier = Modifier .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -168,13 +171,14 @@ private fun StakingRewardBlock( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), + enabled = isRewardsToClaim && isRewardsClaimable, onClick = onRewardsClick, ), ) } @Composable -private fun ActiveStakingBlock(groups: List, onClick: (BalanceState) -> Unit) { +private fun ActiveStakingBlock(groups: ImmutableList, onClick: (BalanceState) -> Unit) { Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { @@ -190,43 +194,30 @@ private fun ActiveStakingBlock(groups: List, onClick: (Bala .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action), ) { - group.items.forEachIndexed { index, balance -> + Text( + text = group.title.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding( + top = TangemTheme.dimens.spacing12, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ), + ) + group.items.forEach { balance -> key(balance.validator.address) { - val caption = combinedReference( - if (group.type == BalanceGroupType.UNSTAKED) { - resourceReference(R.string.staking_details_unbonding_period) - annotatedReference { - appendSpace() - appendColored( - text = balance.unbondingPeriod.resolveReference(), - color = TangemTheme.colors.text.accent, - ) - } - } else { - resourceReference(R.string.app_name) - annotatedReference { - appendSpace() - appendColored( - text = BigDecimalFormatter.formatPercent( - percent = balance.validator.apr.orZero(), - useAbsoluteValue = true, - ), - color = TangemTheme.colors.text.accent, - ) - } - }, - ) InputRowImageInfo( - title = group.title.takeIf { index == 0 }, subtitle = stringReference(balance.validator.name), - caption = caption, - isGrayscaleImage = group.type == BalanceGroupType.UNSTAKED, + caption = getCaption(group.type, balance), + isGrayscaleImage = group.type == BalanceType.UNSTAKING, infoTitle = balance.fiatAmount, infoSubtitle = balance.cryptoAmount, imageUrl = balance.validator.image.orEmpty(), + iconEndRes = R.drawable.ic_chevron_right_24.takeIf { group.isClickable }, modifier = Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), + enabled = group.isClickable, onClick = { onClick(balance) }, ), ) @@ -239,6 +230,41 @@ private fun ActiveStakingBlock(groups: List, onClick: (Bala } } +@Composable +private fun getCaption(balanceType: BalanceType, balance: BalanceState): TextReference { + return if (balanceType == BalanceType.UNSTAKING) { + combinedReference( + resourceReference(R.string.staking_details_unbonding_period), + annotatedReference { + appendSpace() + appendColored( + text = balance.unbondingPeriod.resolveReference(), + color = TangemTheme.colors.text.accent, + ) + }, + ) + } else { + combinedReference( + resourceReference(R.string.app_name), + annotatedReference { + appendSpace() + appendColored( + text = BigDecimalFormatter.formatPercent( + percent = balance.validator.apr.orZero(), + useAbsoluteValue = true, + ), + color = TangemTheme.colors.text.accent, + ) + }, + ) + } +} + +private val textGradientColors = listOf( + TangemColorPalette.White, + Color(0xff8fb4df), +) + // region preview @Preview(showBackground = true, widthDp = 360) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingNavigationButtons.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingNavigationButtons.kt deleted file mode 100644 index 1ce479e4a5..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingNavigationButtons.kt +++ /dev/null @@ -1,201 +0,0 @@ -package com.tangem.features.staking.impl.presentation.ui - -import androidx.compose.animation.* -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.shape.RoundedCornerShape -import androidx.compose.material3.Icon -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.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.tangem.common.ui.amountScreen.ui.SendDoneButtons -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerW12 -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.TangemButtonsDefaults -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.staking.impl.presentation.state.* - -@Composable -internal fun StakingNavigationButtons(uiState: StakingUiState, modifier: Modifier = Modifier) { - val confirmInnerState = (uiState.confirmationState as? StakingStates.ConfirmationState.Data)?.innerState - val isSuccessState = confirmInnerState == InnerConfirmationStakingState.COMPLETED - - Column( - modifier = modifier - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing16, - ), - ) { - val confirmationDataState = uiState.confirmationState as? StakingStates.ConfirmationState.Data - val transactionDoneState = confirmationDataState?.transactionDoneState as? TransactionDoneState.Content - - SendDoneButtons( - txUrl = transactionDoneState?.txUrl.orEmpty(), - onExploreClick = uiState.clickIntents::onExploreClick, - onShareClick = uiState.clickIntents::onShareClick, - isVisible = isSuccessState, - ) - StakingNavigationButton( - uiState = uiState, - modifier = Modifier, - ) - } -} - -@Composable -private fun StakingNavigationButton(uiState: StakingUiState, modifier: Modifier = Modifier) { - val hapticFeedback = LocalHapticFeedback.current - - val isButtonsVisible = isPrevButtonVisible(uiState.currentStep) - - val innerConfirmState = (uiState.confirmationState as? StakingStates.ConfirmationState.Data)?.innerState - val isInProgressInnerState = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS - val isInAssentInnerState = innerConfirmState == InnerConfirmationStakingState.ASSENT - - val showTangemIcon = uiState.currentStep == StakingStep.Confirmation && - (isInProgressInnerState || isInAssentInnerState) - - val buttonTextId = getButtonData(currentState = uiState) - val (isButtonEnabled, isButtonDisplayed) = isButtonEnabled(uiState) - val buttonIcon = if (showTangemIcon) { - TangemButtonIconPosition.End(R.drawable.ic_tangem_24) - } else { - TangemButtonIconPosition.None - } - - Row(modifier = modifier) { - AnimatedVisibility( - visible = isButtonsVisible, - enter = expandHorizontally(expandFrom = Alignment.End), - exit = shrinkHorizontally(shrinkTowards = Alignment.End), - ) { - Row { - Icon( - painter = painterResource(R.drawable.ic_back_24), - tint = TangemTheme.colors.icon.primary1, - contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) - .background(TangemTheme.colors.button.secondary) - .clickable { uiState.clickIntents.onPrevClick() } - .padding(TangemTheme.dimens.spacing12), - ) - SpacerW12() - } - } - AnimatedVisibility( - visible = isButtonDisplayed, - enter = fadeIn(), - exit = fadeOut(), - ) { - TangemButton( - text = stringResource(buttonTextId), - icon = buttonIcon, - enabled = isButtonEnabled && isButtonDisplayed, - onClick = { - if (showTangemIcon) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - onPrimaryClick(uiState) - }, - showProgress = isInProgressInnerState, - modifier = Modifier.fillMaxWidth(), - colors = TangemButtonsDefaults.primaryButtonColors, - ) - } - } -} - -private fun getButtonData(currentState: StakingUiState): Int { - return when (currentState.currentStep) { - StakingStep.InitialInfo -> { - val initialState = currentState.initialInfoState as? StakingStates.InitialInfoState.Data - if (initialState?.yieldBalance is InnerYieldBalanceState.Data) { - R.string.staking_stake_more - } else { - R.string.common_next - } - } - StakingStep.Confirmation -> { - val confirmationState = currentState.confirmationState - if (confirmationState is StakingStates.ConfirmationState.Data) { - if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { - R.string.common_close - } else { - R.string.common_stake - } - } else { - R.string.common_close - } - } - StakingStep.Validators -> R.string.common_continue - StakingStep.Amount, - StakingStep.RewardsValidators, - -> R.string.common_next - } -} - -private fun onPrimaryClick(currentState: StakingUiState) { - when (currentState.currentStep) { - StakingStep.InitialInfo -> { - val initialState = currentState.initialInfoState as? StakingStates.InitialInfoState.Data - if (initialState?.yieldBalance is InnerYieldBalanceState.Data) { - if (initialState.isStakeMoreAvailable) { - currentState.clickIntents.onNextClick() - } - } else { - currentState.clickIntents.onNextClick() - } - } - StakingStep.Amount -> currentState.clickIntents.onNextClick() - StakingStep.Confirmation -> { - val confirmationState = currentState.confirmationState - if (confirmationState is StakingStates.ConfirmationState.Data) { - if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { - currentState.clickIntents.onBackClick() - } else { - currentState.clickIntents.onNextClick() - } - } else { - currentState.clickIntents.onBackClick() - } - } - StakingStep.Validators -> currentState.clickIntents.onNextClick() - StakingStep.RewardsValidators -> Unit - } -} - -private fun isPrevButtonVisible(step: StakingStep): Boolean = when (step) { - StakingStep.InitialInfo, - StakingStep.RewardsValidators, - StakingStep.Confirmation, - -> false - StakingStep.Amount, - StakingStep.Validators, - -> true -} - -private fun isButtonEnabled(uiState: StakingUiState): Pair { - return when (uiState.currentStep) { - StakingStep.InitialInfo -> { - val initialState = uiState.initialInfoState as? StakingStates.InitialInfoState.Data - val isDisplayed = initialState?.isStakeMoreAvailable == true - uiState.initialInfoState.isPrimaryButtonEnabled to isDisplayed - } - StakingStep.Amount -> uiState.amountState.isPrimaryButtonEnabled to true - StakingStep.Confirmation -> uiState.confirmationState.isPrimaryButtonEnabled to true - StakingStep.RewardsValidators -> uiState.rewardsValidatorsState.isPrimaryButtonEnabled to false - StakingStep.Validators -> true to true - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt index c26935b80c..ef94fa0c1d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -11,10 +11,13 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import com.tangem.common.ui.amountScreen.AmountScreenContent +import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet +import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig +import com.tangem.common.ui.navigationButtons.NavigationButtonsBlock import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -29,10 +32,10 @@ import kotlinx.coroutines.flow.withIndex @Composable internal fun StakingScreen(uiState: StakingUiState) { - BackHandler(onBack = uiState.clickIntents::onBackClick) + BackHandler(onBack = uiState.clickIntents::onPrevClick) Column( modifier = Modifier - .background(color = TangemTheme.colors.background.tertiary) + .background(color = TangemTheme.colors.background.secondary) .fillMaxSize() .imePadding() .systemBarsPadding(), @@ -45,8 +48,13 @@ internal fun StakingScreen(uiState: StakingUiState) { uiState = uiState, modifier = Modifier.weight(1f), ) - StakingNavigationButtons( - uiState = uiState, + NavigationButtonsBlock( + buttonState = uiState.buttonsState, + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), ) StakingBottomSheet(bottomSheetConfig = uiState.bottomSheetConfig) } @@ -57,19 +65,12 @@ fun StakingBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) { if (bottomSheetConfig == null) return when (bottomSheetConfig.content) { is StakingInfoBottomSheetConfig -> StakingInfoBottomSheet(bottomSheetConfig) + is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(bottomSheetConfig) } } @Composable private fun SendAppBar(uiState: StakingUiState) { - val titleRes = when (uiState.currentStep) { - StakingStep.Amount -> stringResource(id = R.string.send_amount_label) - StakingStep.InitialInfo, - StakingStep.RewardsValidators, - StakingStep.Validators, - StakingStep.Confirmation, - -> stringResource(id = R.string.common_stake) - } val backIcon = when (uiState.currentStep) { StakingStep.Amount, StakingStep.Validators, @@ -84,10 +85,10 @@ private fun SendAppBar(uiState: StakingUiState) { } } AppBarWithBackButtonAndIcon( - text = titleRes, + text = uiState.title.resolveReference(), backIconRes = backIcon, onBackClick = uiState.clickIntents::onBackClick, - backgroundColor = TangemTheme.colors.background.tertiary, + backgroundColor = TangemTheme.colors.background.secondary, modifier = Modifier.height(TangemTheme.dimens.size56), ) } @@ -155,7 +156,7 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M amountState = uiState.amountState, state = uiState.confirmationState, clickIntents = uiState.clickIntents, - type = uiState.routeType, + type = uiState.actionType, ) StakingStep.Validators -> { val confirmState = uiState.confirmationState diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt index 743979fa67..e6ed596800 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt @@ -1,27 +1,26 @@ package com.tangem.features.staking.impl.presentation.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Text +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable 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.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle +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 com.tangem.core.ui.components.inputrow.InputRowImageSelector -import com.tangem.core.ui.components.rows.CornersToRound -import com.tangem.core.ui.extensions.annotatedReference -import com.tangem.core.ui.extensions.combinedReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +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.utils.BigDecimalFormatter @@ -30,7 +29,7 @@ import com.tangem.features.staking.impl.presentation.state.ValidatorState import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents -import java.math.BigDecimal +import com.tangem.utils.extensions.orZero /** * Staking screen with validators @@ -47,23 +46,6 @@ internal fun StakingValidatorListContent( contentPadding = PaddingValues(bottom = bottomBarHeight), modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing16), ) { - item(key = "HEADER") { - Text( - text = stringResource(R.string.staking_validator), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier - .fillMaxWidth() - .clip(CornersToRound.TOP_2.getShape()) - .background(TangemTheme.colors.background.action) - .padding( - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing8, - ), - ) - } if (state is ValidatorState.Content) { val validators = state.availableValidators items( @@ -77,29 +59,40 @@ internal fun StakingValidatorListContent( subtitle = stringReference(item.name), caption = combinedReference( resourceReference(R.string.staking_details_apr), - annotatedReference( - buildAnnotatedString { - append(" ") - withStyle(style = SpanStyle(color = TangemTheme.colors.text.accent)) { - append( - BigDecimalFormatter.formatPercent(item.apr ?: BigDecimal.ZERO, true), - ) - } - }, - ), + annotatedReference { + appendSpace() + appendColored( + text = BigDecimalFormatter.formatPercent(item.apr.orZero(), true), + color = TangemTheme.colors.text.accent, + ) + }, ), imageUrl = item.image.orEmpty(), isSelected = item == state.chosenValidator, onSelect = { clickIntents.onValidatorSelect(item) }, modifier = Modifier - .clip( - if (index == validators.lastIndex) { - CornersToRound.BOTTOM_2 - } else { - CornersToRound.ZERO - }.getShape(), + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = validators.lastIndex, + radius = TangemTheme.dimens.radius12, + addDefaultPadding = + false, ) .background(TangemTheme.colors.background.action), + selectorContent = { checked, _, _ -> + AnimatedVisibility( + modifier = Modifier, + visible = checked, + ) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_check_24), + ), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + }, ) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt index 68d638aaaa..a9dcad67cf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt @@ -1,13 +1,42 @@ package com.tangem.features.staking.impl.presentation.ui.block +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.CardWithIcon import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.staking.impl.presentation.state.StakingNotification +import kotlinx.collections.immutable.ImmutableList @Composable -internal fun NotificationsBlock(notifications: List) { - notifications.forEach { - Notification(config = it.config, iconTint = TangemTheme.colors.icon.accent) +internal fun NotificationsBlock(notifications: ImmutableList) { + notifications.forEach { notification -> + key(notification) { + if (notification is StakingNotification.Warning.TransactionInProgress) { + CardWithIcon( + title = notification.title.resolveReference(), + description = notification.description.resolveReference(), + icon = { + CircularProgressIndicator( + modifier = Modifier.size(TangemTheme.dimens.size16), + color = TangemTheme.colors.icon.primary1, + strokeWidth = TangemTheme.dimens.size2, + ) + }, + ) + } else { + Notification( + config = notification.config, + iconTint = when (notification) { + is StakingNotification.Error -> TangemTheme.colors.icon.warning + is StakingNotification.Warning -> TangemTheme.colors.icon.accent + }, + ) + } + } } } \ No newline at end of file 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 4dff5385fb..fdca1be81a 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 @@ -16,14 +16,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.R +import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.rows.SelectorRowItem -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.common.ui.R -import com.tangem.common.ui.amountScreen.utils.getCryptoReference -import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.features.staking.impl.presentation.state.FeeState import java.math.BigDecimal @@ -39,8 +39,8 @@ internal fun StakingFeeBlock(feeState: FeeState) { ) { Text( text = stringResource(R.string.common_network_fee_title), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, ) Box( @@ -53,7 +53,14 @@ internal fun StakingFeeBlock(feeState: FeeState) { SelectorRowItem( titleRes = title, iconRes = icon, - preDot = getCryptoReference(feeAmount, feeState.isFeeApproximate), + preDot = stringReference( + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = feeAmount?.value, + cryptoCurrency = feeAmount?.currencySymbol.orEmpty(), + decimals = feeAmount?.decimals ?: 0, + canBeLower = feeState.isFeeApproximate, + ), + ), postDot = if (feeState.isFeeConvertibleToFiat) { getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency) } else { @@ -88,7 +95,7 @@ private fun BoxScope.FeeLoading(feeState: FeeState) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( - height = TangemTheme.dimens.size12, + height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) @@ -107,7 +114,7 @@ private fun BoxScope.FeeError(feeState: FeeState) { Text( text = BigDecimalFormatter.EMPTY_BALANCE_SIGN, color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.body2, + style = TangemTheme.typography.body1, ) } } @@ -117,7 +124,7 @@ private fun BoxScope.FeeError(feeState: FeeState) { @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState.Content) { +private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState) { TangemThemePreview { StakingFeeBlock( feeState = value, @@ -125,11 +132,13 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va } } -private class FeeBlockPreviewProvider : PreviewParameterProvider { +private class FeeBlockPreviewProvider : PreviewParameterProvider { - override val values: Sequence + override val values: Sequence get() = sequenceOf( - feeState, + contentState, + FeeState.Loading, + FeeState.Error, ) private val fee = Fee.Common( @@ -141,7 +150,7 @@ private class FeeBlockPreviewProvider : PreviewParameterProvider Unit) { @@ -39,32 +30,21 @@ internal fun ValidatorBlock(validatorState: ValidatorState, onClick: () -> Unit) interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), onClick = onClick, - ) - .padding(TangemTheme.dimens.spacing12), + ), ) { - Text( - text = stringResource(R.string.staking_validator), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6), - ) if (validatorState is ValidatorState.Content) { - InputRowImageChevron( + InputRowImageInfo( + title = resourceReference(R.string.staking_validator), subtitle = stringReference(validatorState.chosenValidator.name), - caption = combinedReference( - resourceReference(R.string.staking_details_apr), - annotatedReference( - buildAnnotatedString { - append(" ") - withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { - val apr = validatorState.chosenValidator.apr ?: BigDecimal.ZERO - append(BigDecimalFormatter.formatPercent(apr, true)) - } - }, - ), - ), + infoTitle = annotatedReference { + append(resourceReference(R.string.staking_details_apr).resolveReference()) + appendSpace() + appendColored( + text = BigDecimalFormatter.formatPercent(validatorState.chosenValidator.apr.orZero(), true), + color = TangemTheme.colors.text.accent, + ) + }, imageUrl = validatorState.chosenValidator.image.orEmpty(), - showChevron = validatorState.isClickable, ) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt index 8302d36d14..db14a50094 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingClickIntents.kt @@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.viewmodel import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.transformers.InfoType import kotlinx.collections.immutable.ImmutableList @@ -12,13 +13,20 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun onBackClick() - fun onNextClick(pendingActions: ImmutableList = persistentListOf()) + fun onNextClick( + actionType: StakingActionCommonType? = null, + pendingActions: ImmutableList = persistentListOf(), + ) + + fun onActionClick(pendingAction: PendingAction?) fun onPrevClick() + fun onInitialInfoBannerClick() + fun onInfoClick(infoType: InfoType) - override fun onAmountNext() = onNextClick() + override fun onAmountNext() = onNextClick(actionType = null) fun openValidators() @@ -26,10 +34,12 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun openRewardsValidators() - fun selectRewardValidator(rewardValue: String) - fun onActiveStake(activeStake: BalanceState) + fun showApprovalBottomSheet() + + fun onApprovalClick() + fun onExploreClick() fun onShareClick() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt index 1aa55e58c3..33f5b8df76 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt @@ -7,22 +7,32 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData -import com.tangem.common.extensions.hexToBytes +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRoute import com.tangem.common.routing.bundle.unbundle import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.staking.* +import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase +import com.tangem.domain.transaction.usecase.GetAllowanceUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.models.UserWallet @@ -35,14 +45,19 @@ import com.tangem.features.staking.impl.presentation.state.transformers.amount.A import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountCurrencyChangeStateTransformer import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountMaxValueStateTransformer import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountPasteDismissStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetConfirmationStateAssentApprovalTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer import com.tangem.utils.Provider -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.* import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber +import java.math.BigDecimal import javax.inject.Inject import kotlin.properties.Delegates @@ -53,23 +68,31 @@ internal class StakingViewModel @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val getStakingTransactionUseCase: GetStakingTransactionUseCase, + private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, private val estimateGasUseCase: EstimateGasUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, private val submitHashUseCase: SubmitHashUseCase, private val isStakeMoreAvailableUseCase: IsStakeMoreAvailableUseCase, + private val stakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase, + private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, + private val getAllowanceUseCase: GetAllowanceUseCase, + private val getFeeUseCase: GetFeeUseCase, + private val isApproveNeededUseCase: IsApproveNeededUseCase, + private val clipboardManager: ClipboardManager, + private val vibratorHapticManager: VibratorHapticManager, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver, StakingClickIntents { val uiState: StateFlow = stateController.uiState val value: StakingUiState get() = uiState.value - var stakingStateRouter: StakingStateRouter by Delegates.notNull() - private set + private var stakingStateRouter: StakingStateRouter by Delegates.notNull() private val cryptoCurrencyId: CryptoCurrency.ID = savedStateHandle.get(AppRoute.Staking.CRYPTO_CURRENCY_ID_KEY) @@ -85,11 +108,17 @@ internal class StakingViewModel @Inject constructor( ?: error("This screen can't be opened without `Yield`") private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + private var feeCryptoCurrencyStatus: CryptoCurrencyStatus? = null private var innerRouter: InnerStakingRouter by Delegates.notNull() private var userWallet: UserWallet by Delegates.notNull() private var appCurrency: AppCurrency by Delegates.notNull() + private var stakingApproval: StakingApproval = StakingApproval.Empty + private val allowanceTaskScheduler = SingleTaskScheduler() + + private var approvalJobHolder: JobHolder = JobHolder() + init { subscribeOnSelectedAppCurrency() subscribeOnBalanceHiding() @@ -100,55 +129,73 @@ internal class StakingViewModel @Inject constructor( stakingStateRouter.onBackClick() } - override fun onNextClick(pendingActions: ImmutableList) { - handleOnNextConfirmationClick() + override fun onNextClick(actionType: StakingActionCommonType?, pendingActions: ImmutableList) { + if (actionType != null) { + stateController.update { it.copy(actionType = actionType) } + } stakingStateRouter.onNextClick() if (isAssentState()) { - estimateGas(pendingActions) + getFee(pendingActions) } } - private fun handleOnNextConfirmationClick() { + override fun onActionClick(pendingAction: PendingAction?) { + handleOnNextConfirmationClick(pendingAction) + stakingStateRouter.onNextClick() + } + + private fun handleOnNextConfirmationClick(pendingAction: PendingAction?) { if (isAssentState()) { viewModelScope.launch { - stateController.update(SetConfirmationStateInProgressTransformer()) + stateController.update(SetConfirmationStateInProgressTransformer(pendingAction)) val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data ?: error("No confirmation state") val validatorState = confirmationState.validatorState as? ValidatorState.Content ?: error("No validator provided") - val pendingActions = confirmationState.pendingActions + val amountState = value.amountState as? AmountState.Data ?: error("No amount provided") + val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee ?: error("No fee provided") val stakingTransaction = getStakingTransactionUseCase( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, params = ActionParams( - actionCommonType = getStakingCommonType(), + actionCommonType = value.actionType, integrationId = yield.id, - amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value - ?: error("No amount provided"), + amount = amountValue, address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value ?: error("No available address"), validatorAddress = validatorState.chosenValidator.address, token = yield.token, - passthrough = pendingActions.firstOrNull()?.passthrough, - type = pendingActions.firstOrNull()?.type, + passthrough = pendingAction?.passthrough, + type = pendingAction?.type, ), ).getOrElse { error(it) } - stakingTransaction.unsignedTransaction?.let { - sendStakingTransaction( - transactionId = stakingTransaction.id, - gasEstimate = stakingTransaction.gasEstimate ?: error("No gas estimate available"), - txData = TransactionData.Compiled(value = it.hexToBytes()), - pendingActions = pendingActions, - ) - } ?: error("No unsigned transaction available") + stakingTransaction + .filterNot { it.type == StakingTransactionType.APPROVAL } + .forEach { transaction -> + val (constructedTransaction, transactionData) = getConstructedStakingTransactionUseCase( + networkId = cryptoCurrencyStatus.currency.network.id.value, + fee = fee, + transactionId = transaction.id, + ).getOrNull() ?: error("No constructed transaction") + + sendStakingTransaction( + transactionId = constructedTransaction.id, + gasEstimate = constructedTransaction.gasEstimate ?: error("No gas estimate available"), + txData = transactionData, + pendingActionList = confirmationState.pendingActions, + ) + } } } } - private fun estimateGas(pendingActions: ImmutableList) { + private fun getFee(pendingActions: ImmutableList) { viewModelScope.launch { stateController.update( SetConfirmationStateLoadingTransformer( @@ -156,37 +203,112 @@ internal class StakingViewModel @Inject constructor( ), ) val cryptoCurrencyValue = cryptoCurrencyStatus.value + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val validatorState = confirmationState.validatorState as? ValidatorState.Content + ?: error("No validator provided") - val stakingGasEstimate = estimateGasUseCase( - params = ActionParams( - actionCommonType = getStakingCommonType(), - integrationId = yield.id, - amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value - ?: error("No amount provided"), - address = cryptoCurrencyValue.networkAddress?.defaultAddress?.value - ?: error("No available address"), - validatorAddress = yield.validators.getOrNull(0)?.address ?: error("No available validator"), - token = yield.token, - passthrough = pendingActions.firstOrNull()?.passthrough, - type = pendingActions.firstOrNull()?.type, - ), - ).getOrElse { error("Can't get fee info") } + val amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: error("No amount provided") + val sourceAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value + ?: error("No available address") + val validatorAddress = validatorState.chosenValidator.address - stateController.update( - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - stakingGasEstimate = stakingGasEstimate, - pendingActionList = pendingActions, - ), - ) + val approval = stakingApproval as? StakingApproval.Needed + if (approval != null) { + val allowance = getAllowanceUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + spenderAddress = approval.spenderAddress, + ).getOrElse { BigDecimal.ZERO } + + if (allowance < amount) { + getApproveFee( + amount = amount, + validatorAddress = validatorAddress, + ) + } else { + estimateGas( + pendingActions = pendingActions, + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + ) + } + } else { + estimateGas( + pendingActions = pendingActions, + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + ) + } } } + private suspend fun estimateGas( + pendingActions: ImmutableList, + amount: BigDecimal, + sourceAddress: String, + validatorAddress: String, + ) { + val pendingAction = pendingActions.firstOrNull() + val stakingGasEstimate = estimateGasUseCase( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, + params = ActionParams( + actionCommonType = value.actionType, + integrationId = yield.id, + amount = amount, + address = sourceAddress, + validatorAddress = validatorAddress, + token = yield.token, + passthrough = pendingAction?.passthrough, + type = pendingAction?.type, + ), + ).getOrElse { + stateController.update(AddStakingErrorTransformer(it)) + return + } + + stateController.update( + SetConfirmationStateAssentTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + stakingGasEstimate = stakingGasEstimate, + pendingActionList = pendingActions, + ), + ) + } + + private suspend fun getApproveFee(amount: BigDecimal, validatorAddress: String) { + val approvalFee = getFeeUseCase( + amount = amount, + destination = validatorAddress, + userWallet = userWallet, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).getOrElse { + // TODO staking error + return + } + + stateController.update( + SetConfirmationStateAssentApprovalTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = approvalFee, + ), + ) + } + override fun onPrevClick() { stakingStateRouter.onPrevClick() } + override fun onInitialInfoBannerClick() { + // innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL) + } + override fun onInfoClick(infoType: InfoType) { stateController.update( ShowInfoBottomSheetStateTransformer(infoType) { @@ -196,7 +318,7 @@ internal class StakingViewModel @Inject constructor( } override fun onAmountValueChange(value: String) { - stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, value)) + stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, yield, value)) } override fun onAmountPasteTriggerDismiss() { @@ -204,7 +326,7 @@ internal class StakingViewModel @Inject constructor( } override fun onMaxValueClick() { - stateController.update(AmountMaxValueStateTransformer(cryptoCurrencyStatus)) + stateController.update(AmountMaxValueStateTransformer(cryptoCurrencyStatus, yield)) } override fun onCurrencyChangeClick(isFiat: Boolean) { @@ -217,25 +339,123 @@ internal class StakingViewModel @Inject constructor( stateController.update(ValidatorSelectChangeTransformer(validator)) } - override fun openRewardsValidators() { - stateController.update { it.copy(routeType = RouteType.CLAIM) } - onNextClick() - } - - override fun selectRewardValidator(rewardValue: String) { - stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, rewardValue)) - onNextClick() - } + override fun openRewardsValidators() = onNextClick(actionType = StakingActionCommonType.PENDING_REWARDS) override fun onActiveStake(activeStake: BalanceState) { - val routeType = if (activeStake.pendingActions.isEmpty()) { - RouteType.UNSTAKE + val actionType = if (activeStake.pendingActions.isEmpty()) { + StakingActionCommonType.EXIT } else { - RouteType.OTHER + StakingActionCommonType.PENDING_OTHER } - stateController.update { it.copy(routeType = routeType) } - stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, activeStake.cryptoValue)) - onNextClick(activeStake.pendingActions) + stateController.update(ValidatorSelectChangeTransformer(activeStake.validator)) + stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, yield, activeStake.cryptoValue)) + onNextClick(actionType, activeStake.pendingActions) + } + + override fun showApprovalBottomSheet() { + stateController.update( + ShowApprovalBottomSheetTransformer( + appCurrencyProvider = Provider { appCurrency }, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) { + stateController.update(DismissBottomSheetStateTransformer()) + }, + ) + } + + override fun onApprovalClick() { + viewModelScope.launch { + stateController.update( + SetApprovalBottomSheetInProgressTransformer { + stateController.update(DismissBottomSheetStateTransformer()) + }, + ) + + val tokenCryptoCurrency = + cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: error("No token currency") + val amountValue = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: error("No amount provided") + + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee ?: error("No fee provided") + val approval = stakingApproval as? StakingApproval.Needed ?: error("No staking approve spender address") + + val approvalTransaction = createApprovalTransactionUseCase( + amount = amountValue, + contractAddress = tokenCryptoCurrency.contractAddress, + spenderAddress = approval.spenderAddress, + fee = fee, + cryptoCurrency = tokenCryptoCurrency, + userWalletId = userWalletId, + ).fold( + ifLeft = { error -> + Timber.e(error.toString()) + stateController.update( + SetConfirmationStateAssentApprovalTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = TransactionFee.Single(fee), + ), + ) + // TODO staking error + return@launch + }, + ifRight = { it }, + ) + + sendTransactionUseCase( + txData = approvalTransaction, + userWallet = userWallet, + network = tokenCryptoCurrency.network, + ).fold( + ifLeft = { error -> + Timber.e(error.toString()) + stateController.update( + SetConfirmationStateAssentApprovalTransformer( + appCurrencyProvider = Provider { appCurrency }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fee = TransactionFee.Single(fee), + ), + ) + // TODO staking error + }, + ifRight = { + stateController.update(SetApprovalInProgressTransformer) + stateController.update(DismissBottomSheetStateTransformer()) + awaitForAllowance(confirmationState.pendingActions) + }, + ) + }.saveIn(approvalJobHolder) + } + + private fun awaitForAllowance(pendingActions: ImmutableList) { + val approval = stakingApproval as? StakingApproval.Needed ?: return + allowanceTaskScheduler.scheduleTask( + scope = viewModelScope, + task = PeriodicTask( + delay = ALLOWANCE_UPDATE_DELAY, + task = { + runCatching { + getAllowanceUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + spenderAddress = approval.spenderAddress, + ).getOrElse { BigDecimal.ZERO } + } + }, + onSuccess = { allowance -> + val amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: error("No amount provided") + if (allowance >= amount) { + getFee(pendingActions) + allowanceTaskScheduler.cancelTask() + } + }, + onError = { /* no-op */ }, + ), + ) } override fun onExploreClick() { @@ -249,7 +469,16 @@ internal class StakingViewModel @Inject constructor( } override fun onShareClick() { - // TODO staking analytics event + val confirmationDataState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data + val transactionDoneState = confirmationDataState?.transactionDoneState as? TransactionDoneState.Content + val txUrl = transactionDoneState?.txUrl + + if (txUrl != null) { + vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) + clipboardManager.setText(text = txUrl) + } + + // TODO staking [REDACTED_TASK_KEY] } fun setRouter(router: InnerStakingRouter, stateRouter: StakingStateRouter) { @@ -257,6 +486,10 @@ internal class StakingViewModel @Inject constructor( this.stakingStateRouter = stateRouter } + private fun setupApprovalNeeded() { + stakingApproval = isApproveNeededUseCase(cryptoCurrencyStatus.currency).getOrElse { StakingApproval.Empty } + } + private fun subscribeOnCurrencyStatusUpdates() { viewModelScope.launch { getUserWalletUseCase(userWalletId).fold( @@ -269,8 +502,11 @@ internal class StakingViewModel @Inject constructor( ) getCryptoCurrencyStatusSyncUseCase(userWalletId, cryptoCurrencyId).fold( ifRight = { + feeCryptoCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase(userWalletId, it).getOrNull() cryptoCurrencyStatus = it + setupApprovalNeeded() + val networkId = cryptoCurrencyStatus.currency.network.id val isStakeMoreAvailable = isStakeMoreAvailableUseCase(networkId) stateController.update( @@ -278,6 +514,7 @@ internal class StakingViewModel @Inject constructor( clickIntents = this@StakingViewModel, yield = yield, isStakeMoreAvailable = isStakeMoreAvailable.getOrElse { false }, + isApprovalNeeded = stakingApproval is StakingApproval.Needed, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, userWalletProvider = Provider { userWallet }, appCurrencyProvider = Provider { appCurrency }, @@ -317,7 +554,7 @@ internal class StakingViewModel @Inject constructor( transactionId: String, gasEstimate: StakingGasEstimate, txData: TransactionData, - pendingActions: ImmutableList, + pendingActionList: ImmutableList, ) { sendTransactionUseCase( txData = txData, @@ -329,16 +566,16 @@ internal class StakingViewModel @Inject constructor( stateController.update( SetConfirmationStateAssentTransformer( appCurrencyProvider = Provider { appCurrency }, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, stakingGasEstimate = gasEstimate, - pendingActionList = pendingActions, + pendingActionList = pendingActionList, ), ) // todo add error dialog }, ifRight = { txHash -> submitHash(transactionId, txHash) - + updateStakeBalance() val txUrl = getExplorerTransactionUrlUseCase( txHash = txHash, networkId = cryptoCurrencyStatus.currency.network.id, @@ -347,7 +584,7 @@ internal class StakingViewModel @Inject constructor( stateController.update( SetConfirmationStateCompletedTransformer( appCurrencyProvider = Provider { appCurrency }, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, stakingGasEstimate = gasEstimate, txUrl = txUrl, ), @@ -371,17 +608,27 @@ internal class StakingViewModel @Inject constructor( } } + private fun updateStakeBalance() { + viewModelScope.launch { + stakingYieldBalanceUseCase( + userWalletId = userWalletId, + address = CryptoCurrencyAddress( + cryptoCurrencyStatus.currency, + cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value.orEmpty(), + ), + refresh = true, + ) + } + } + private fun isAssentState(): Boolean { return value.currentStep == StakingStep.Confirmation && (value.confirmationState as? StakingStates.ConfirmationState.Data)?.innerState == InnerConfirmationStakingState.ASSENT } - private fun getStakingCommonType() = when (value.routeType) { - RouteType.STAKE -> StakingActionCommonType.ENTER - RouteType.UNSTAKE -> StakingActionCommonType.EXIT - RouteType.CLAIM, - RouteType.OTHER, - -> StakingActionCommonType.PENDING + private companion object { + const val WHAT_IS_STAKING_ARTICLE_URL = "TODO staking" + const val ALLOWANCE_UPDATE_DELAY = 10_000L } } \ No newline at end of file diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index 5998ddafd0..f9d6ff0013 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -37,7 +37,7 @@ dependencies { implementation(projects.domain.wallets.models) /** Data */ - implementation(projects.data.tokens) + implementation(projects.data.common) /** Tangem SDKs */ implementation(deps.tangem.blockchain) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index edd4767043..31b1659a73 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -7,9 +7,8 @@ import arrow.core.raise.either import arrow.core.right import com.squareup.moshi.Moshi import com.tangem.blockchain.common.* -import com.tangem.blockchain.extensions.Result import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow @@ -362,10 +361,10 @@ internal class DefaultSwapRepository @Inject constructor( ), ) ?: error("Cannot cast to Approver") - return when (result) { - is Result.Success -> result.data - is Result.Failure -> error(result.error) - } + return result.fold( + onSuccess = { it }, + onFailure = { error(it) }, + ) } override suspend fun getApproveData( diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 7cca09753d..9b44aacbef 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -4,7 +4,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync -import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.datasource.local.preferences.utils.getObjectMapSync import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId @@ -75,7 +75,7 @@ class DefaultSwapTransactionRepository( scanResponse: ScanResponse, ): Flow?> { return withContext(dispatchers.io) { - val txStatuses = appPreferencesStore.getObjectMap( + val txStatuses = appPreferencesStore.getObjectMapSync( key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, ) appPreferencesStore.getObjectList( 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 8e41921960..33112a9591 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 @@ -1,7 +1,7 @@ package com.tangem.feature.swap.converters -import com.tangem.data.tokens.utils.ResponseCryptoCurrenciesFactory -import com.tangem.data.tokens.utils.UserTokensResponseFactory +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.currency.UserTokensResponseFactory import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 2616e04be8..854e53526b 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -113,6 +113,7 @@ data class TxFee( val decimals: Int, val cryptoSymbol: String, val feeType: FeeType, + val gasPremium: Long?, ) enum class FeeType { 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 ac6b130966..de0468d9ff 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 @@ -22,11 +22,11 @@ import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.QuotesRepository -import com.tangem.domain.tokens.utils.convertToAmount import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -216,7 +216,7 @@ internal class SwapInteractorImpl @Inject constructor( permissionOptions.approveData.approveData } val approveTransaction = createTransactionUseCase( - amount = BigDecimal.ZERO.convertToAmount(permissionOptions.fromToken), + amount = BigDecimal.ZERO.convertToSdkAmount(permissionOptions.fromToken), fee = getFeeForTransaction( fee = permissionOptions.txFee, blockchain = Blockchain.fromId(permissionOptions.fromToken.network.id.value), @@ -268,6 +268,17 @@ internal class SwapInteractorImpl @Inject constructor( amountToSwap: String, selectedFee: FeeType, ): Map { + Timber.i( + """ + Find the best quote + |- fromToken: $fromToken + |- toToken: $toToken + |- providers: $providers + |- amountToSwap: $amountToSwap + |- selectedFee: $selectedFee + """.trimIndent(), + ) + return providers.map { provider -> val amountDecimal = toBigDecimalOrNull(amountToSwap) if (amountDecimal == null || amountDecimal.signum() == 0) { @@ -311,7 +322,7 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, ): Pair { - val quotes = repository.findBestQuote( + val maybeQuotes = repository.findBestQuote( fromContractAddress = fromToken.currency.getContractAddress(), fromNetwork = fromToken.currency.network.backendId, toContractAddress = toToken.currency.getContractAddress(), @@ -324,7 +335,7 @@ internal class SwapInteractorImpl @Inject constructor( ) val fromTokenAddress = getTokenAddress(fromToken.currency) - val isAllowedToSpend = quotes.fold( + val isAllowedToSpend = maybeQuotes.fold( ifRight = { quotes -> quotes.allowanceContract?.let { isAllowedToSpend(networkId, fromToken.currency, amount, it) @@ -352,7 +363,7 @@ internal class SwapInteractorImpl @Inject constructor( } else { provider to getQuotesState( provider = provider, - quoteDataModel = quotes, + quoteDataModel = maybeQuotes, amount = amount, fromToken = fromToken, toToken = toToken, @@ -507,7 +518,7 @@ internal class SwapInteractorImpl @Inject constructor( ) validateTransactionUseCase( - amount = amount.value.convertToAmount(fromToken), + amount = amount.value.convertToSdkAmount(fromToken), fee = fee, memo = null, destination = getTokenAddress(fromToken), @@ -573,6 +584,19 @@ internal class SwapInteractorImpl @Inject constructor( includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, ): SwapTransactionState { + Timber.i( + """ + Swap + |- swapProvider: $swapProvider + |- swapData: $swapData + |- currencyToSend: $currencyToSend + |- currencyToGet: $currencyToGet + |- amountToSwap: $amountToSwap + |- includeFeeInAmount: $includeFeeInAmount + |- fee: $fee + """.trimIndent(), + ) + val cardId = getSelectedWallet()?.scanResponse?.card?.cardId ?: return SwapTransactionState.UnknownError if (isDemoCardUseCase(cardId)) return SwapTransactionState.DemoMode @@ -767,7 +791,7 @@ internal class SwapInteractorImpl @Inject constructor( if (demoConfig.isDemoCardId(cardId)) return SwapTransactionState.UnknownError val txData = createTransactionUseCase( - amount = amount.value.convertToAmount(currencyToSend.currency), + amount = amount.value.convertToSdkAmount(currencyToSend.currency), fee = getFeeForTransaction( fee = txFee, blockchain = Blockchain.fromId(currencyToSend.currency.network.id.value), @@ -888,6 +912,21 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = fee.gasLimit.toLong(), ) } + blockchain == Blockchain.Filecoin -> { + val gasUnitPrice = fee.feeValue.divide( + BigDecimal(fee.gasLimit), + Blockchain.Filecoin.decimals(), + RoundingMode.HALF_UP, + ) + Fee.Filecoin( + amount = feeAmount, + gasUnitPrice = gasUnitPrice + .movePointRight(Blockchain.Filecoin.decimals()) + .toLong(), + gasLimit = fee.gasLimit.toLong(), + gasPremium = requireNotNull(fee.gasPremium), + ) + } else -> Fee.Common(feeAmount) } } @@ -1579,6 +1618,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = minFee.fee.decimals, cryptoSymbol = minFee.fee.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (minFee as? ProxyFee.Filecoin)?.gasPremium, ), priorityFee = TxFee( feeValue = priorityFeeValue, @@ -1591,6 +1631,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = normalFee.fee.decimals, cryptoSymbol = normalFee.fee.currencySymbol, feeType = FeeType.PRIORITY, + gasPremium = (normalFee as? ProxyFee.Filecoin)?.gasPremium, ), ) } @@ -1633,6 +1674,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = singleFee.fee.decimals, cryptoSymbol = singleFee.fee.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (singleFee as? ProxyFee.Filecoin)?.gasPremium, ), ) } @@ -1688,6 +1730,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = normalFee.amount.decimals, cryptoSymbol = normalFee.amount.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (normalFee as? Fee.Filecoin)?.gasPremium, ), priorityFee = TxFee( feeValue = feePriority, @@ -1700,6 +1743,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = priorityFee.amount.decimals, cryptoSymbol = priorityFee.amount.currencySymbol, feeType = FeeType.PRIORITY, + gasPremium = (priorityFee as? Fee.Filecoin)?.gasPremium, ), ) } @@ -1731,6 +1775,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = normal.amount.decimals, cryptoSymbol = normal.amount.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (normal as? Fee.Filecoin)?.gasPremium, ), ) } @@ -1778,6 +1823,7 @@ internal class SwapInteractorImpl @Inject constructor( is Fee.Ethereum -> gasLimit.toInt() is Fee.VeChain -> gasLimit.toInt() is Fee.Aptos -> gasLimit.toInt() + is Fee.Filecoin -> gasLimit.toInt() else -> 0 } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt new file mode 100644 index 0000000000..4d066c82d2 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt @@ -0,0 +1,20 @@ +package com.tangem.feature.swap.preview + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.models.states.FeeItemState + +object FeeItemStatePreview { + + val state = FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "1000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(1000$)", + isClickable = false, + onClick = {}, + ) + + val stateClickable = state.copy(isClickable = true) +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt index e4c3b974ca..b3cb10ce22 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt @@ -1,20 +1,21 @@ package com.tangem.feature.swap.ui +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.SpacerH24 -import com.tangem.core.ui.components.rows.SimpleActionRow -import com.tangem.core.ui.extensions.resolveReference +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.swap.domain.models.ui.FeeType import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.preview.FeeItemStatePreview @Composable fun FeeItemBlock(state: FeeItemState) { @@ -25,53 +26,37 @@ fun FeeItemBlock(state: FeeItemState) { @Composable fun FeeItem(state: FeeItemState.Content) { - Box( + val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})" + val icon = R.drawable.ic_chevron_right_24.takeIf { state.isClickable } + InputRowDefault( + title = state.title, + text = stringReference(description), + iconRes = icon, modifier = Modifier - .background( - color = TangemTheme.colors.background.action, - shape = TangemTheme.shapes.roundedCornersXMedium, - ) .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .background(color = TangemTheme.colors.background.action) .clickable( + enabled = state.isClickable, onClick = state.onClick, - ) - .fillMaxWidth() - .defaultMinSize(minHeight = TangemTheme.dimens.size68), - ) { - val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})" - SimpleActionRow( - modifier = Modifier.padding( - start = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing12, ), - title = state.title.resolveReference(), - description = description, - isClickable = state.isClickable, - ) + ) +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun FeeItem_Preview(@PreviewParameter(FeeItemPreviewProvider::class) data: FeeItemState.Content) { + TangemThemePreview { + FeeItem(data) } } -@Preview -@Composable -private fun FeeItemPreview() { - val state = FeeItemState.Content( - feeType = FeeType.NORMAL, - title = stringReference("Fee"), - amountCrypto = "1000", - symbolCrypto = "MATIC", - amountFiatFormatted = "(1000$)", - isClickable = false, - onClick = {}, - ) - Column { - TangemThemePreview(isDark = false) { - FeeItem(state = state) - } - - SpacerH24() - - TangemThemePreview(isDark = true) { - FeeItem(state = state) - } - } -} \ No newline at end of file +private class FeeItemPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + FeeItemStatePreview.state, + FeeItemStatePreview.state.copy(isClickable = true), + ) +} +// endregion \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index 315c7a064a..9bf6689b1c 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -263,7 +263,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) { Column { Text( text = stringResource(R.string.express_provider), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.secondary, modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), ) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index f02139cb42..eb128c84db 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -208,7 +208,7 @@ internal class SwapViewModel @Inject constructor( ) } }.onFailure { - Timber.tag(loggingTag).e(it) + Timber.e(it) applyInitialTokenChoice( state = TokensDataStateExpress.EMPTY, @@ -1212,7 +1212,6 @@ internal class SwapViewModel @Inject constructor( } private companion object { - const val loggingTag = "SwapViewModel" const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L const val DEBOUNCE_AMOUNT_DELAY = 1000L diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 01480f6da6..0e94a8abeb 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { /** Compose */ implementation(deps.compose.material) + implementation(deps.compose.material3) implementation(deps.compose.foundation) implementation(deps.compose.navigation) implementation(deps.compose.navigation.hilt) @@ -33,9 +34,11 @@ dependencies { /** Other libraries */ implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) implementation(deps.timber) /** Core modules */ + implementation(projects.core.datasource) implementation(projects.core.featuretoggles) implementation(projects.core.ui) implementation(projects.core.utils) @@ -45,5 +48,6 @@ dependencies { implementation(projects.features.tester.api) /** Other modules */ + implementation(projects.common.routing) implementation(projects.libs.crypto) } \ No newline at end of file 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 62206ea5e5..10d761ffb9 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 @@ -1,18 +1,22 @@ package com.tangem.feature.tester.presentation import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.tangem.common.routing.AppRouter import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeActivity import com.tangem.feature.tester.presentation.actions.TesterActionsScreen import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel +import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen +import com.tangem.feature.tester.presentation.environments.viewmodels.EnvironmentsTogglesViewModel import com.tangem.feature.tester.presentation.featuretoggles.ui.FeatureTogglesScreen import com.tangem.feature.tester.presentation.featuretoggles.viewmodels.FeatureTogglesViewModel import com.tangem.feature.tester.presentation.menu.state.TesterMenuContentState @@ -37,6 +41,9 @@ internal class TesterActivity : ComposeActivity() { @Inject lateinit var appFinisher: AppFinisher + @Inject + lateinit var appRouter: AppRouter + private val innerTesterRouter: InnerTesterRouter get() = requireNotNull(testerRouter as? InnerTesterRouter) { "TesterRouter must be InnerTesterRouter for tester feature" @@ -60,8 +67,9 @@ internal class TesterActivity : ComposeActivity() { composable(route = TesterScreen.MENU.name) { TesterMenuScreen( state = TesterMenuContentState( - onBackClick = innerTesterRouter::back, + onBackClick = { appRouter.pop { finish() } }, onFeatureTogglesClick = { innerTesterRouter.open(TesterScreen.FEATURE_TOGGLES) }, + onEnvironmentTogglesClick = { innerTesterRouter.open(TesterScreen.ENVIRONMENTS_TOGGLES) }, onTesterActionsClick = { innerTesterRouter.open(TesterScreen.TESTER_ACTIONS) }, ), ) @@ -75,6 +83,16 @@ internal class TesterActivity : ComposeActivity() { FeatureTogglesScreen(state = viewModel.uiState) } + composable(route = TesterScreen.ENVIRONMENTS_TOGGLES.name) { + val viewModel = hiltViewModel().apply { + setupNavigation(innerTesterRouter) + } + + EnvironmentTogglesScreen( + uiModel = viewModel.uiState.collectAsState().value, + ) + } + composable(route = TesterScreen.TESTER_ACTIONS.name) { val viewModel = hiltViewModel().apply { setupNavigation(innerTesterRouter) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/state/EnvironmentTogglesScreenUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/state/EnvironmentTogglesScreenUM.kt new file mode 100644 index 0000000000..44a644a9af --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/state/EnvironmentTogglesScreenUM.kt @@ -0,0 +1,35 @@ +package com.tangem.feature.tester.presentation.environments.state + +import androidx.annotation.StringRes +import kotlinx.collections.immutable.ImmutableSet + +/** + * Content state of environment toggles screen + * + * @property title title + * @property apiInfoList environment toggles list + * @property onEnvironmentSelect the lambda to be invoked when button is pressed + * @property onBackClick the lambda to be invoked when back button is pressed + */ +internal data class EnvironmentTogglesScreenUM( + @StringRes val title: Int, + val apiInfoList: ImmutableSet, + val onEnvironmentSelect: (id: String, environment: String) -> Unit, + val onBackClick: () -> Unit, +) { + + /** + * Api info + * + * @property name api name + * @property select select environment + * @property url select url + * @property environments list of environments + */ + data class ApiInfoUM( + val name: String, + val select: String, + val url: String, + val environments: ImmutableSet, + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt new file mode 100644 index 0000000000..b9c442ce86 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt @@ -0,0 +1,169 @@ +package com.tangem.feature.tester.presentation.environments.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.feature.tester.impl.R +import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM +import kotlinx.collections.immutable.persistentSetOf + +/** + * Screen with environment toggles list + * + * @param uiModel screen state + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun EnvironmentTogglesScreen(uiModel: EnvironmentTogglesScreenUM) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary), + ) { + stickyHeader { + AppBarWithBackButton( + onBackClick = uiModel.onBackClick, + text = stringResource(id = uiModel.title), + ) + } + + itemsIndexed( + items = uiModel.apiInfoList.toTypedArray(), + key = { _, info -> info.name }, + ) { index, info -> + EnvironmentButtons( + uiModel = info, + onSelect = { isChange -> uiModel.onEnvironmentSelect(info.name, isChange) }, + modifier = Modifier.padding( + bottom = if (index == uiModel.apiInfoList.toTypedArray().lastIndex) { + TangemTheme.dimens.spacing0 + } else { + TangemTheme.dimens.spacing10 + }, + ), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EnvironmentButtons( + uiModel: EnvironmentTogglesScreenUM.ApiInfoUM, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = uiModel.name, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + style = TangemTheme.typography.h3, + ) + + AnimatedContent(targetState = uiModel.url, label = "") { + Text( + text = it, + color = TangemTheme.colors.text.accent, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.body2, + ) + } + } + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + uiModel.environments.onEachIndexed { index, environment -> + key(environment) { + SegmentedButton( + selected = environment == uiModel.select, + onClick = { onSelect(environment) }, + shape = when (index) { + 0 -> RoundedCornerShape( + topStart = TangemTheme.dimens.radius12, + bottomStart = TangemTheme.dimens.radius12, + ) + uiModel.environments.toTypedArray().lastIndex -> RoundedCornerShape( + topEnd = TangemTheme.dimens.radius12, + bottomEnd = TangemTheme.dimens.radius12, + ) + else -> RectangleShape + }, + colors = SegmentedButtonDefaults.colors( + activeContainerColor = TangemTheme.colors.control.checked, + activeContentColor = TangemTheme.colors.text.primary2, + inactiveContainerColor = TangemTheme.colors.control.unchecked, + inactiveContentColor = TangemTheme.colors.text.primary1, + ), + border = BorderStroke(0.dp, TangemTheme.colors.background.tertiary), + ) { + Text(text = environment, style = TangemTheme.typography.subtitle2) + } + } + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewFeatureTogglesScreen() { + TangemThemePreview { + var select by remember { mutableStateOf(ApiEnvironment.DEV.name) } + + EnvironmentTogglesScreen( + uiModel = EnvironmentTogglesScreenUM( + title = R.string.environment_toggles, + apiInfoList = persistentSetOf( + EnvironmentTogglesScreenUM.ApiInfoUM( + name = ApiConfig.ID.Express.name, + select = select, + url = "https://api.express.tangem.com", + environments = persistentSetOf( + ApiEnvironment.DEV.name, + ApiEnvironment.STAGE.name, + ApiEnvironment.PROD.name, + ), + ), + EnvironmentTogglesScreenUM.ApiInfoUM( + name = ApiConfig.ID.TangemTech.name, + select = select, + url = "https://api.express.tangem.com", + environments = persistentSetOf( + ApiEnvironment.DEV.name, + ApiEnvironment.PROD.name, + ), + ), + ), + onEnvironmentSelect = { _: String, s1: String -> select = s1 }, + onBackClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt new file mode 100644 index 0000000000..e2d3b7e6a3 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt @@ -0,0 +1,98 @@ +package com.tangem.feature.tester.presentation.environments.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.datasource.api.common.config.managers.MutableApiConfigsManager +import com.tangem.feature.tester.impl.BuildConfig +import com.tangem.feature.tester.impl.R +import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM +import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModel for screen with list of environment toggles + * + * @param apiConfigsManager manager for getting information about the api configs + * +[REDACTED_AUTHOR] + */ +@HiltViewModel +internal class EnvironmentsTogglesViewModel @Inject constructor( + apiConfigsManager: ApiConfigsManager, +) : ViewModel() { + + /** Current ui state */ + val uiState: StateFlow + get() = _uiState + + private val _uiState = MutableStateFlow(value = getInitialState()) + + private val mutableApiConfigsManager: MutableApiConfigsManager = + requireNotNull(apiConfigsManager as? MutableApiConfigsManager) { + "MutableApiConfigsManager isn't available in build type ${BuildConfig.BUILD_TYPE}." + } + + init { + subscribeOnApiConfigs() + } + + /** Setup navigation state property by router [router] */ + fun setupNavigation(router: InnerTesterRouter) { + _uiState.update { + it.copy(onBackClick = router::back) + } + } + + private fun subscribeOnApiConfigs() { + mutableApiConfigsManager.configs + .onEach { configs -> + _uiState.update { + it.copy(apiInfoList = configs.toUiModel()) + } + } + .launchIn(viewModelScope) + } + + private fun Map.toUiModel(): ImmutableSet { + return mapNotNull { + val (config, currentEnvironment) = it + + if (config.environmentConfigs.size <= 1) return@mapNotNull null + + EnvironmentTogglesScreenUM.ApiInfoUM( + name = config.id.name, + select = currentEnvironment.name, + url = config.environmentConfigs.firstOrNull { it.environment == currentEnvironment }?.baseUrl + ?: error("Current environment's url isn't found"), + environments = config.environmentConfigs + .map { environmentConfig -> environmentConfig.environment.name } + .toImmutableSet(), + ) + } + .toImmutableSet() + } + + private fun getInitialState(): EnvironmentTogglesScreenUM { + return EnvironmentTogglesScreenUM( + title = R.string.environment_toggles, + apiInfoList = persistentSetOf(), + onEnvironmentSelect = ::onToggleValueChange, + onBackClick = {}, + ) + } + + private fun onToggleValueChange(id: String, name: String) { + viewModelScope.launch { + mutableApiConfigsManager.changeEnvironment(id = id, environment = ApiEnvironment.valueOf(name)) + } + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt index 736e01b6c6..c1e4e290bb 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt @@ -1,6 +1,7 @@ package com.tangem.feature.tester.presentation.featuretoggles.state import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle +import kotlinx.collections.immutable.ImmutableList /** * Content state of feature toggles screen @@ -11,7 +12,7 @@ import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatur * @property onApplyChangesClick the lambda to be invoked when apply changes button is pressed */ internal data class FeatureTogglesContentState( - val featureToggles: List, + val featureToggles: ImmutableList, val onToggleValueChange: (String, Boolean) -> Unit, val onBackClick: () -> Unit, val onApplyChangesClick: () -> Unit, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt index b43d13d2f3..0ebd8cadbf 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt @@ -3,11 +3,7 @@ package com.tangem.feature.tester.presentation.featuretoggles.ui import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -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.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Switch @@ -26,6 +22,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.tester.impl.R import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState +import kotlinx.collections.immutable.persistentListOf /** * Screen with feature toggles list @@ -56,7 +53,8 @@ internal fun FeatureTogglesScreen(state: FeatureTogglesContentState) { PrimaryButton( text = stringResource(id = R.string.apply_changes), onClick = state.onApplyChangesClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(TangemTheme.dimens.spacing16), ) } @@ -98,7 +96,7 @@ private fun PreviewFeatureTogglesScreen() { TangemThemePreview { FeatureTogglesScreen( state = FeatureTogglesContentState( - featureToggles = listOf( + featureToggles = persistentListOf( TesterFeatureToggle(name = "FEATURE_TOGGLE_1", isEnabled = true), TesterFeatureToggle(name = "FEATURE_TOGGLE_2", isEnabled = false), ), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt index 2d810e1ebf..6200dd283a 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt @@ -13,6 +13,8 @@ import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureToggle import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import javax.inject.Inject @@ -64,9 +66,10 @@ internal class FeatureTogglesViewModel @Inject constructor( } } - private fun MutableFeatureTogglesManager.getTesterFeatureToggles(): List { + private fun MutableFeatureTogglesManager.getTesterFeatureToggles(): ImmutableList { return this .getFeatureToggles() .map { TesterFeatureToggle(it.key, it.value) } + .toImmutableList() } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt index a570b21660..a074e734b9 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt @@ -3,12 +3,14 @@ package com.tangem.feature.tester.presentation.menu.state /** * Content state of tester menu screen * - * @property onBackClick the lambda to be invoked when back button is pressed - * @property onFeatureTogglesClick the lambda to be invoked when feature toggles button is pressed - * @property onTesterActionsClick the lambda to be invoked when tester actions button is pressed + * @property onBackClick the lambda to be invoked when back button is pressed + * @property onFeatureTogglesClick the lambda to be invoked when feature toggles button is pressed + * @property onEnvironmentTogglesClick the lambda to be invoked when environment toggles button is pressed + * @property onTesterActionsClick the lambda to be invoked when tester actions button is pressed */ data class TesterMenuContentState( val onBackClick: () -> Unit, val onFeatureTogglesClick: () -> Unit, + val onEnvironmentTogglesClick: () -> Unit, val onTesterActionsClick: () -> Unit, ) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt index 39d9c5b95c..105ea332fd 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt @@ -1,12 +1,9 @@ package com.tangem.feature.tester.presentation.menu.ui import android.content.res.Configuration +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -27,6 +24,8 @@ import com.tangem.feature.tester.presentation.menu.state.TesterMenuContentState */ @Composable internal fun TesterMenuScreen(state: TesterMenuContentState) { + BackHandler(onBack = state.onBackClick) + Column( modifier = Modifier .fillMaxSize() @@ -36,6 +35,7 @@ internal fun TesterMenuScreen(state: TesterMenuContentState) { onBackClick = state.onBackClick, text = stringResource(id = R.string.tester_menu), ) + Column( modifier = Modifier .padding( @@ -50,12 +50,13 @@ internal fun TesterMenuScreen(state: TesterMenuContentState) { onClick = state.onFeatureTogglesClick, modifier = Modifier.fillMaxWidth(), ) + PrimaryButton( - text = stringResource(R.string.stand_toggles), - onClick = state.onFeatureTogglesClick, + text = stringResource(R.string.environment_toggles), + onClick = state.onEnvironmentTogglesClick, modifier = Modifier.fillMaxWidth(), - enabled = false, ) + PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.tester_actions), @@ -74,6 +75,7 @@ private fun PreviewTesterMenuScreen() { state = TesterMenuContentState( onBackClick = {}, onFeatureTogglesClick = {}, + onEnvironmentTogglesClick = {}, onTesterActionsClick = {}, ), ) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt index 903f287179..2d7622855b 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt @@ -6,5 +6,5 @@ package com.tangem.feature.tester.presentation.navigation [REDACTED_AUTHOR] */ internal enum class TesterScreen { - MENU, FEATURE_TOGGLES, TESTER_ACTIONS + MENU, FEATURE_TOGGLES, ENVIRONMENTS_TOGGLES, TESTER_ACTIONS } \ 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 ffc3114361..dfd6f4d39c 100644 --- a/features/tester/impl/src/main/res/values/strings.xml +++ b/features/tester/impl/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ Tester menu Feature toggles Apply changes - Stand toggles + Environment toggles Tester actions Hide all currencies Toggle app theme - %s diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 182ed49b47..e7625b4013 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -129,7 +129,7 @@ internal object TokenDetailsPreviewData { onBalanceSelect = {}, displayCryptoBalance = "966,96 XLM", displayFiatBalance = "91,50$", - isBalanceSelectorEnabled = false, + isBalanceSelectorEnabled = true, ) val balanceError = TokenDetailsBalanceBlockState.Error( actionButtons = actionButtons, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index 878dc66047..300083d876 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -4,7 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt index f3104d8636..c5b6078527 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt @@ -91,19 +91,4 @@ internal sealed class TokenDetailsActionButton(val config: ActionButtonConfig) { dimContent = dimContent, ), ) - - /** - * Staking - * - * @property dimContent determines whether the button content will be dimmed - * @property onClick lambda be invoked when Swap button is clicked - */ - data class Stake(val dimContent: Boolean, override val onClick: () -> Unit) : TokenDetailsActionButton( - config = ActionButtonConfig( - text = TextReference.Res(id = R.string.common_stake), - iconResId = R.drawable.ic_staking_24, - onClick = onClick, - dimContent = dimContent, - ), - ) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt index afe2413ce6..dbf8a99f3c 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt @@ -38,12 +38,6 @@ internal class TokenDetailsActionButtonsConverter( onLongClick = clickIntents::onCopyAddress, ) } - is TokenActionsState.ActionState.Stake -> { - TokenDetailsActionButton.Stake( - dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, - onClick = { clickIntents.onStakeClick(action.unavailabilityReason) }, - ) - } is TokenActionsState.ActionState.Sell -> { TokenDetailsActionButton.Sell( dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index b5b0f8a5c6..6aa3923cd9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -11,10 +11,11 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.feature.tokendetails.presentation.tokendetails.state.BalanceType +import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState @@ -31,9 +32,11 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal +@Suppress("LongParameterList") internal class TokenDetailsLoadedBalanceConverter( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, + private val stakingEntryInfoProvider: Provider, private val symbol: String, private val decimals: Int, private val clickIntents: TokenDetailsClickIntents, @@ -136,39 +139,28 @@ internal class TokenDetailsLoadedBalanceConverter( private fun getYieldBalance(status: CryptoCurrencyStatus, state: TokenDetailsState): StakingBlockUM { val yieldBalance = status.value.yieldBalance as? YieldBalance.Data - val stakingCryptoAmount = yieldBalance?.getTotalStakingBalance() - val stakingRewardAmount = yieldBalance?.getRewardStakingBalance() - val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } - return if (stakingCryptoAmount.isNullOrZero()) { - StakingBlockUM.Loading(state.tokenInfoBlockState.iconState) - } else { - StakingBlockUM.Staked( - cryptoAmount = stakingCryptoAmount, - fiatAmount = stakingFiatAmount, - cryptoValue = stringReference( - BigDecimalFormatter.formatCryptoAmount(stakingCryptoAmount, symbol, decimals), - ), - fiatValue = stringReference( - BigDecimalFormatter.formatFiatAmount( - stakingFiatAmount, - appCurrencyProvider().code, - appCurrencyProvider().symbol, - ), - ), - rewardValue = resourceReference( - R.string.staking_details_rewards_to_claim, - wrappedList( - BigDecimalFormatter.formatFiatAmount( - stakingRewardAmount, - appCurrencyProvider().code, - appCurrencyProvider().symbol, - ), - ), - ), - onStakeClicked = clickIntents::onStakeBannerClick, - ) + val stakingEntryInfo = stakingEntryInfoProvider.invoke() + val iconState = state.tokenInfoBlockState.iconState + + return when { + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> { + getStakeAvailableState(stakingEntryInfo, iconState) + } + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo == null -> { + StakingBlockUM.Error(iconState = iconState) + } + else -> { + val stakingRewardAmount = yieldBalance?.getRewardStakingBalance() + val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } + + getStakedState( + stakingCryptoAmount = stakingCryptoAmount, + stakingFiatAmount = stakingFiatAmount, + stakingRewardAmount = stakingRewardAmount, + ) + } } } @@ -195,6 +187,54 @@ internal class TokenDetailsLoadedBalanceConverter( } } + private fun getStakeAvailableState( + stakingEntryInfo: StakingEntryInfo, + iconState: IconState, + ): StakingBlockUM.StakeAvailable { + return StakingBlockUM.StakeAvailable( + interestRate = BigDecimalFormatter.formatPercent( + percent = stakingEntryInfo.interestRate, + useAbsoluteValue = true, + ), + periodInDays = stakingEntryInfo.periodInDays, + tokenSymbol = stakingEntryInfo.tokenSymbol, + iconState = iconState, + onStakeClicked = clickIntents::onStakeBannerClick, + ) + } + + private fun getStakedState( + stakingCryptoAmount: BigDecimal?, + stakingFiatAmount: BigDecimal?, + stakingRewardAmount: BigDecimal?, + ): StakingBlockUM.Staked { + return StakingBlockUM.Staked( + cryptoAmount = stakingCryptoAmount, + fiatAmount = stakingFiatAmount, + cryptoValue = stringReference( + BigDecimalFormatter.formatCryptoAmount(stakingCryptoAmount, symbol, decimals), + ), + fiatValue = stringReference( + BigDecimalFormatter.formatFiatAmount( + stakingFiatAmount, + appCurrencyProvider().code, + appCurrencyProvider().symbol, + ), + ), + rewardValue = resourceReference( + R.string.staking_details_rewards_to_claim, + wrappedList( + BigDecimalFormatter.formatFiatAmount( + stakingRewardAmount, + appCurrencyProvider().code, + appCurrencyProvider().symbol, + ), + ), + ), + onStakeClicked = clickIntents::onStakeBannerClick, + ) + } + private fun CryptoCurrencyStatus.Value.toContentConfig(currencySymbol: String): MarketPriceBlockState.Content { return MarketPriceBlockState.Content( currencySymbol = currencySymbol, 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 3d6080faba..402e674bec 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 @@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.MutableStateFlow internal class TokenDetailsStateFactory( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, + private val stakingEntryInfoProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, private val clickIntents: TokenDetailsClickIntents, private val featureToggles: TokenDetailsFeatureToggles, @@ -79,6 +80,7 @@ internal class TokenDetailsStateFactory( TokenDetailsLoadedBalanceConverter( currentStateProvider = currentStateProvider, appCurrencyProvider = appCurrencyProvider, + stakingEntryInfoProvider = stakingEntryInfoProvider, symbol = symbol, decimals = decimals, clickIntents = clickIntents, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index d640fc414e..6a8a7087ed 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -9,7 +9,7 @@ import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Quote -import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel 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 index d3e4bdff81..b814e9e554 100644 --- 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 @@ -147,17 +147,20 @@ private fun BalanceButtons(state: TokenDetailsBalanceBlockState) { .padding(top = TangemTheme.dimens.spacing11) .width(IntrinsicSize.Min), ) { config -> + val style = if (state.selectedBalanceType == config.type) { + TangemTheme.typography.caption1 + } else { + TangemTheme.typography.caption2 + } Text( text = config.title.resolveReference(), color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.caption1, + style = style, maxLines = 1, modifier = Modifier .padding( - start = TangemTheme.dimens.spacing5, - end = TangemTheme.dimens.spacing5, - top = TangemTheme.dimens.spacing3, - bottom = TangemTheme.dimens.spacing3, + horizontal = TangemTheme.dimens.spacing4, + vertical = TangemTheme.dimens.spacing6, ) .align(Alignment.Center), ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt index 0d5d72efd5..c1ce6e3abd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt @@ -33,7 +33,7 @@ internal fun StakingBalanceBlock(state: StakingBlockUM.Staked, modifier: Modifie modifier = modifier .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action) + .background(TangemTheme.colors.background.primary) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt index fc2733a1ba..67920c922a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt @@ -7,7 +7,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Quote -import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index bffe54c894..ca70ba95b1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -19,7 +19,8 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.haptic.HapticManager +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -34,6 +35,7 @@ import com.tangem.domain.staking.GetStakingAvailabilityUseCase import com.tangem.domain.staking.GetStakingEntryInfoUseCase import com.tangem.domain.staking.GetYieldUseCase import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.tokens.* import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.legacy.TradeCryptoAction.TransactionInfo @@ -41,10 +43,10 @@ import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.NetworkAddress import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason -import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent -import com.tangem.domain.tokens.models.analytics.TokenReceiveAnalyticsEvent -import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent -import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.transaction.error.AssociateAssetError import com.tangem.domain.transaction.usecase.AssociateAssetUseCase @@ -119,7 +121,7 @@ internal class TokenDetailsViewModel @Inject constructor( private val associateAssetUseCase: AssociateAssetUseCase, private val reduxStateHolder: ReduxStateHolder, private val analyticsEventsHandler: AnalyticsEventHandler, - private val hapticManager: HapticManager, + private val vibratorHapticManager: VibratorHapticManager, private val clipboardManager: ClipboardManager, private val getUserWalletUseCase: GetUserWalletUseCase, tokenDetailsFeatureToggles: TokenDetailsFeatureToggles, @@ -144,15 +146,16 @@ internal class TokenDetailsViewModel @Inject constructor( private val refreshStateJobHolder = JobHolder() private val warningsJobHolder = JobHolder() private val swapTxJobHolder = JobHolder() - private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null - - private var swapTxStatusTaskScheduler = SingleTaskScheduler>() - private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null + private var stakingEntryInfo: StakingEntryInfo? = null + private var swapTxStatusTaskScheduler = SingleTaskScheduler>() + private val stateFactory = TokenDetailsStateFactory( currentStateProvider = Provider { uiState.value }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), + stakingEntryInfoProvider = Provider { stakingEntryInfo }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, clickIntents = this, symbol = cryptoCurrency.symbol, @@ -399,7 +402,8 @@ internal class TokenDetailsViewModel @Inject constructor( cryptoCurrencyId = cryptoCurrency.id, symbol = cryptoCurrency.symbol, ) - internalUiState.value = stateFactory.getStateWithStaking(stakingInfo) + + stakingEntryInfo = stakingInfo.getOrNull() } } } @@ -568,7 +572,7 @@ internal class TokenDetailsViewModel @Inject constructor( viewModelScope.launch(dispatchers.main) { val extendedKey = getExtendedPublicKeyForCurrencyUseCase( userWalletId, - cryptoCurrency.network.derivationPath, + cryptoCurrency.network, ).fold( ifLeft = { Timber.e(it.cause?.localizedMessage.orEmpty()) @@ -577,7 +581,7 @@ internal class TokenDetailsViewModel @Inject constructor( ifRight = { it }, ) if (extendedKey.isNotBlank()) { - hapticManager.vibrateMeduim() + vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) clipboardManager.setText(text = extendedKey) internalUiState.value = stateFactory.getStateAndTriggerEvent( state = internalUiState.value, @@ -799,7 +803,7 @@ internal class TokenDetailsViewModel @Inject constructor( val addresses = networkAddress.availableAddresses.mapToAddressModels(cryptoCurrency).toImmutableList() val defaultAddress = addresses.firstOrNull()?.value ?: return null - hapticManager.vibrateMeduim() + vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) clipboardManager.setText(text = defaultAddress) analyticsEventsHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(cryptoCurrency.symbol)) return resourceReference(R.string.wallet_notification_address_copied) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt index fa0bdbd72d..868de285aa 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation import android.os.Bundle import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.arkivanov.decompose.defaultComponentContext import com.tangem.core.decompose.context.DefaultAppComponentContext @@ -10,7 +11,7 @@ import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.screen.ComposeFragment import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.features.markets.MarketsFeatureToggles -import com.tangem.features.markets.component.MarketsListComponent +import com.tangem.features.markets.component.MarketsEntryComponent import com.tangem.features.wallet.navigation.WalletRouter import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.AndroidEntryPoint @@ -32,7 +33,7 @@ internal class WalletFragment : ComposeFragment() { internal lateinit var walletRouter: WalletRouter @Inject - internal lateinit var marketsListComponentFactory: MarketsListComponent.Factory + internal lateinit var marketsEntryComponentFactory: MarketsEntryComponent.Factory @Inject internal lateinit var coroutineDispatcherProvider: CoroutineDispatcherProvider @@ -43,7 +44,7 @@ internal class WalletFragment : ComposeFragment() { @Inject internal lateinit var marketsFeatureToggles: MarketsFeatureToggles - private var marketsListComponent: MarketsListComponent? = null + private var marketsEntryComponent: MarketsEntryComponent? = null private val _walletRouter: InnerWalletRouter get() = requireNotNull(walletRouter as? InnerWalletRouter) { @@ -61,15 +62,19 @@ internal class WalletFragment : ComposeFragment() { hiltComponentBuilder = componentBuilder, ) - marketsListComponent = marketsListComponentFactory.create(appContext) + marketsEntryComponent = marketsEntryComponentFactory.create(appContext) } } @Composable override fun ScreenContent(modifier: Modifier) { _walletRouter.Initialize( - onFinish = requireActivity()::finish, - marketsListComponent = marketsListComponent, + onFinish = remember(requireActivity()) { + { + requireActivity().finish() + } + }, + marketsEntryComponent = marketsEntryComponent, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt index e1f6a29f77..0ad7e674a7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt @@ -97,7 +97,7 @@ internal object WalletPreviewData { id = UUID.randomUUID().toString(), iconState = coinIconState, titleState = TokenItemState.TitleState.Content(text = "Polygon", hasPending = true), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $"), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $", hasStaked = true), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,412 MATIC"), cryptoPriceState = TokenItemState.CryptoPriceState.Unknown, onItemClick = {}, @@ -117,7 +117,7 @@ internal object WalletPreviewData { id = UUID.randomUUID().toString(), iconState = tokenIconState, titleState = TokenItemState.TitleState.Content(text = "Polygon"), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $"), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $", hasStaked = false), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,412 MATIC"), cryptoPriceState = TokenItemState.CryptoPriceState.Content( price = "312 USD", diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt index 01c4e11d48..1917d4e4c8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt @@ -403,7 +403,10 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider { FiatAmountText( text = if (isBalanceHidden) StringsSigns.STARS else state.text, - modifier, + hasStaked = state.hasStaked, + modifier = modifier, ) } is TokenFiatAmountState.Loading -> { @@ -32,15 +41,31 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool } @Composable -private fun FiatAmountText(text: String, modifier: Modifier = Modifier) { - Text( - text = text, +private fun FiatAmountText(text: String, hasStaked: Boolean, modifier: Modifier = Modifier) { + Row( modifier = modifier, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TangemTheme.typography.body2, - ) + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = hasStaked, + ) { + Icon( + painter = rememberVectorPainter(image = ImageVector.vectorResource(R.drawable.ic_staking_24)), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing4) + .size(TangemTheme.dimens.size12), + ) + } + Text( + text = text, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography.body2, + ) + } } private fun Modifier.placeholderSize(): Modifier = composed { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt index 6338c9f8b0..2ece6053fc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt @@ -129,7 +129,10 @@ internal sealed class TokenItemState { @Immutable sealed class FiatAmountState { - data class Content(val text: String) : FiatAmountState() + data class Content( + val text: String, + val hasStaked: Boolean = false, + ) : FiatAmountState() object Loading : FiatAmountState() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/deeplink/WalletDeepLinksHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/deeplink/WalletDeepLinksHandler.kt index 3c1e89fbeb..d34c68bd50 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/deeplink/WalletDeepLinksHandler.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/deeplink/WalletDeepLinksHandler.kt @@ -13,7 +13,7 @@ import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.legacy.TradeCryptoAction.TransactionInfo import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.CoroutineScope 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 c90cea3084..a70acf53cd 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 @@ -25,7 +25,7 @@ import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensScree import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensViewModel import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletViewModel -import com.tangem.features.markets.component.MarketsListComponent +import com.tangem.features.markets.component.MarketsEntryComponent import kotlin.properties.Delegates /** Default implementation of wallet feature router */ @@ -41,7 +41,7 @@ internal class DefaultWalletRouter( override fun getEntryFragment(): Fragment = WalletFragment.create() @Composable - override fun Initialize(onFinish: () -> Unit, marketsListComponent: MarketsListComponent?) { + override fun Initialize(onFinish: () -> Unit, marketsEntryComponent: MarketsEntryComponent?) { this.onFinish = onFinish NavHost( @@ -56,7 +56,7 @@ internal class DefaultWalletRouter( WalletScreen( state = viewModel.uiState.collectAsStateWithLifecycle().value, - marketsListComponent = marketsListComponent, + marketsEntryComponent = marketsEntryComponent, ) } @@ -140,7 +140,7 @@ internal class DefaultWalletRouter( } override fun openManageTokensScreen() { - router.push(AppRoute.ManageTokens) + router.push(AppRoute.ManageTokens(readOnlyContent = false)) } override fun openScanFailedDialog(onTryAgain: () -> Unit) { 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 0666e1191a..ee4229c970 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 @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.markets.component.MarketsListComponent +import com.tangem.features.markets.component.MarketsEntryComponent import com.tangem.features.wallet.navigation.WalletRouter /** @@ -24,7 +24,7 @@ internal interface InnerWalletRouter : WalletRouter { * @param onFinish finish activity callback */ @Composable - fun Initialize(onFinish: () -> Unit, marketsListComponent: MarketsListComponent?) + fun Initialize(onFinish: () -> Unit, marketsEntryComponent: MarketsEntryComponent?) /** Pop back stack */ fun popBackStack() 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 1a15d9ee54..2686a1ec9d 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 @@ -3,7 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt index 9285575d9e..160e0d11da 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt @@ -1,8 +1,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.model +import androidx.compose.runtime.Immutable import com.tangem.core.ui.event.StateEvent import kotlinx.collections.immutable.ImmutableList +@Immutable internal data class WalletScreenState( val onBackClick: () -> Unit, val topBarConfig: WalletTopBarConfig, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt index d7639404cc..9abebae38f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter +import com.tangem.common.extensions.isZero import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter @@ -55,6 +56,7 @@ internal class TokenItemStateConverter( ), fiatAmountState = TokenItemState.FiatAmountState.Content( text = getFormattedFiatAmount(), + hasStaked = !getStakedBalance().isZero(), ), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = getFormattedAmount()), cryptoPriceState = getCryptoPriceState(), @@ -64,21 +66,22 @@ internal class TokenItemStateConverter( } private fun CryptoCurrencyStatus.getFormattedAmount(): String { - val yieldBalance = (value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() - val amount = value.amount?.plus(yieldBalance) ?: return DASH_SIGN + val amount = value.amount?.plus(getStakedBalance()) ?: return DASH_SIGN return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) } private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String { - val yieldBalance = (value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() - val fiatYieldBalance = value.fiatRate?.times(yieldBalance).orZero() + val fiatYieldBalance = value.fiatRate?.times(getStakedBalance()).orZero() val fiatAmount = value.fiatAmount?.plus(fiatYieldBalance) ?: return DASH_SIGN val appCurrency = appCurrencyProvider() return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol) } + private fun CryptoCurrencyStatus.getStakedBalance() = + (value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() + private fun CryptoCurrencyStatus.mapToUnreachableTokenItemState() = TokenItemState.Unreachable( id = currency.id.value, iconState = iconStateConverter.convert(value = this), 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 806518dbe8..b8dd2da1b7 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 @@ -49,6 +49,8 @@ import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TestTags @@ -70,12 +72,13 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balances import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.depositButton import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.component.MarketsListComponent +import com.tangem.features.markets.component.BottomSheetState.* +import com.tangem.features.markets.component.MarketsEntryComponent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @Composable -internal fun WalletScreen(state: WalletScreenState, marketsListComponent: MarketsListComponent?) { +internal fun WalletScreen(state: WalletScreenState, marketsEntryComponent: MarketsEntryComponent?) { BackHandler(onBack = state.onBackClick) // It means that screen is still initializing @@ -98,7 +101,7 @@ internal fun WalletScreen(state: WalletScreenState, marketsListComponent: Market snackbarHostState = snackbarHostState, isAutoScroll = isAutoScroll, onAutoScrollReset = { isAutoScroll.value = false }, - marketsListComponent = marketsListComponent, + marketsEntryComponent = marketsEntryComponent, alertConfig = alertConfig, ) @@ -119,7 +122,7 @@ private fun WalletContent( walletsListState: LazyListState, snackbarHostState: SnackbarHostState, isAutoScroll: State, - marketsListComponent: MarketsListComponent?, + marketsEntryComponent: MarketsEntryComponent?, alertConfig: WalletAlertState?, onAutoScrollReset: () -> Unit, ) { @@ -216,7 +219,7 @@ private fun WalletContent( ) } - if (marketsListComponent != null) { + if (marketsEntryComponent != null) { val bottomSheetState = remember { mutableStateOf(BottomSheetState.COLLAPSED) } @@ -232,7 +235,7 @@ private fun WalletContent( alertConfig = alertConfig, onBottomSheetStateChange = { bottomSheetState.value = it }, bottomSheetContent = { - marketsListComponent.BottomSheetContent( + marketsEntryComponent.BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = { headerSize = it }, modifier = Modifier, @@ -316,15 +319,15 @@ private fun BaseScaffold( @Suppress("LongParameterList", "LongMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable -private fun BaseScaffoldWithMarkets( +private inline fun BaseScaffoldWithMarkets( state: WalletScreenState, selectedWallet: WalletState, snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, - bottomSheetContent: @Composable () -> Unit, + crossinline bottomSheetContent: @Composable () -> Unit, alertConfig: WalletAlertState?, - onBottomSheetStateChange: (BottomSheetState) -> Unit, - content: @Composable () -> Unit, + noinline onBottomSheetStateChange: (BottomSheetState) -> Unit, + crossinline content: @Composable () -> Unit, ) { // show the bottom sheet if there is at least one multicurrency wallet val showManageTokensBottomSheet = remember(state.wallets) { @@ -332,11 +335,13 @@ private fun BaseScaffoldWithMarkets( } val bottomSheetState = rememberSheetStateEnhanced( initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden, - confirmValueChange = { sheetValue -> - when { - sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false - sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false - else -> true + confirmValueChange = remember(showManageTokensBottomSheet) { + { sheetValue -> + when { + sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false + sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false + else -> true + } } }, skipHiddenState = showManageTokensBottomSheet, @@ -344,14 +349,6 @@ private fun BaseScaffoldWithMarkets( val keyboardShown = keyboardAsState() - BottomSheetStateEffects( - bottomSheetState = bottomSheetState, - showManageTokensBottomSheet = showManageTokensBottomSheet, - alertConfig = alertConfig, - keyboardShown = keyboardShown, - onBottomSheetStateChange = onBottomSheetStateChange, - ) - val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = bottomSheetState, snackbarHostState = snackbarHostState, @@ -360,77 +357,89 @@ private fun BaseScaffoldWithMarkets( val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight + val maxHeight = LocalWindowSize.current.height val coroutineScope = rememberCoroutineScope() + val backgroundPrimary = TangemTheme.colors.background.primary - BottomSheetScaffold( - snackbarHost = { - WalletSnackbarHost( - snackbarHostState = it, - event = state.event, - modifier = Modifier - .padding(bottom = TangemTheme.dimens.spacing4) - .navigationBarsPadding(), - ) - }, - containerColor = TangemTheme.colors.background.secondary, - sheetContainerColor = TangemTheme.colors.background.primary, - scaffoldState = scaffoldState, - sheetPeekHeight = peekHeight, - sheetDragHandle = { - Hand(modifier = Modifier.background(color = TangemTheme.colors.background.primary)) - }, - sheetTonalElevation = 8.dp, - sheetShadowElevation = 8.dp, - sheetContent = { - BoxWithConstraints { - Box( + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(backgroundPrimary) }, + ) { + val backgroundColor = LocalMainBottomSheetColor.current + + BottomSheetStateEffects( + bottomSheetState = bottomSheetState, + showManageTokensBottomSheet = showManageTokensBottomSheet, + alertConfig = alertConfig, + keyboardShown = keyboardShown, + onBottomSheetStateChange = onBottomSheetStateChange, + ) + + BottomSheetScaffold( + snackbarHost = { + WalletSnackbarHost( + snackbarHostState = it, + event = state.event, modifier = Modifier - .sizeIn(maxHeight = maxHeight - statusBarHeight) - .align(Alignment.BottomCenter), + .padding(bottom = TangemTheme.dimens.spacing4) + .navigationBarsPadding(), + ) + }, + containerColor = TangemTheme.colors.background.secondary, + sheetContainerColor = backgroundColor.value, + scaffoldState = scaffoldState, + sheetPeekHeight = peekHeight, + sheetDragHandle = { + Hand(modifier = Modifier.background(color = backgroundColor.value)) + }, + sheetTonalElevation = 8.dp, + sheetShadowElevation = 8.dp, + sheetContent = { + Box( + modifier = Modifier.sizeIn(maxHeight = maxHeight - statusBarHeight - handComposableComponentHeight), ) { bottomSheetContent() } - } - // hide bottom sheet when back pressed - BackHandler( - keyboardShown.value is Keyboard.Closed && - bottomSheetState.currentValue == SheetValue.Expanded, - ) { - coroutineScope.launch { bottomSheetState.partialExpand() } - } - }, - content = { _ -> - val pullRefreshState = rememberPullRefreshState( - refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - onRefresh = { - selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) - }, - ) - - Column { - WalletTopBar(config = state.topBarConfig) - Box( - modifier = Modifier.pullRefresh(pullRefreshState), + // hide bottom sheet when back pressed + BackHandler( + keyboardShown.value is Keyboard.Closed && + bottomSheetState.currentValue == SheetValue.Expanded, ) { - content() - - WalletPullToRefreshIndicator( - isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) + coroutineScope.launch { bottomSheetState.partialExpand() } } - } + }, + content = { _ -> + val pullRefreshState = rememberPullRefreshState( + refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + onRefresh = { + selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) + }, + ) - BottomSheetScrim( - color = BottomSheetDefaults.ScrimColor, - visible = bottomSheetState.targetValue == SheetValue.Expanded, - onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, - ) - }, - ) + Column { + WalletTopBar(config = state.topBarConfig) + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + ) { + content() + + WalletPullToRefreshIndicator( + isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + + BottomSheetScrim( + color = BottomSheetDefaults.ScrimColor, + visible = bottomSheetState.targetValue == SheetValue.Expanded, + onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, + ) + }, + ) + } } @Composable @@ -545,9 +554,9 @@ private fun BottomSheetStateEffects( LaunchedEffect(isSheetHidden) { onBottomSheetStateChange( if (isSheetHidden) { - BottomSheetState.COLLAPSED + COLLAPSED } else { - BottomSheetState.EXPANDED + EXPANDED }, ) } @@ -640,7 +649,7 @@ private fun WalletScreen_Preview(@PreviewParameter(WalletScreenPreviewProvider:: TangemThemePreview { WalletScreen( state = data, - marketsListComponent = null, + marketsEntryComponent = null, ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt index 06bb5b53a9..b38bc57b61 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.viewmodels +import androidx.compose.runtime.Stable import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -43,6 +44,7 @@ import timber.log.Timber import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") +@Stable @HiltViewModel internal class WalletViewModel @Inject constructor( private val stateHolder: WalletStateController, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt index aeb7ea5c03..26d3d0c945 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt @@ -35,7 +35,7 @@ internal class WalletsUpdateActionResolver @Inject constructor( getUpdateContentAction(currentState, wallets, selectedWallet) } - Timber.d("Resolved action: $action") + Timber.i("Resolved action: $action") return action } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt index c6b5eb1cff..98590b9a89 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt @@ -13,7 +13,8 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.haptic.HapticManager +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.common.util.cardTypesResolver @@ -26,8 +27,8 @@ import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.NetworkAddress import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason -import com.tangem.domain.tokens.models.analytics.TokenReceiveAnalyticsEvent -import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId @@ -91,7 +92,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val dispatchers: CoroutineDispatcherProvider, private val reduxStateHolder: ReduxStateHolder, - private val hapticManager: HapticManager, + private val vibratorHapticManager: VibratorHapticManager, private val clipboardManager: ClipboardManager, private val getYieldUseCase: GetYieldUseCase, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { @@ -192,7 +193,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( val addresses = networkAddress.availableAddresses.mapToAddressModels(cryptoCurrency).toImmutableList() val defaultAddress = addresses.firstOrNull()?.value ?: return null - hapticManager.vibrateMeduim() + vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) clipboardManager.setText(text = defaultAddress) analyticsEventHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(cryptoCurrency.symbol)) return resourceReference(R.string.wallet_notification_address_copied) @@ -370,6 +371,9 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } override fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWallet.walletId)) + viewModelScope.launch { val userWalletId = stateHolder.getSelectedWalletId() val cryptoCurrency = cryptoCurrencyStatus.currency diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt index b8ee6c5ef5..0e557c8e46 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt @@ -14,7 +14,7 @@ import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase import com.tangem.domain.settings.ShouldShowTravalaPromoWalletUseCase import com.tangem.domain.tokens.FetchTokenListUseCase import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent +import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType import com.tangem.domain.wallets.models.UnlockWalletsError import com.tangem.domain.wallets.models.UserWallet diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index b536883c87..6a52565547 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -88,11 +88,11 @@ markdown = "0.7.2" # endregion Other libraries # region Tangem -tangemBlockchainSdk = "release-app_5.13-738" +tangemBlockchainSdk = "release-app_5.14-742" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "release-app_5.13-376" +tangemCardSdk = "release-app_5.14-379" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ -tangemVico = "2.0.0-alpha.21-tangem14" +tangemVico = "2.0.0-alpha.25-tangem16" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ # endregion Tangem @@ -155,6 +155,7 @@ androidx-datastore = { module = "androidx.datastore:datastore-preferences", vers # region AndroidX # region Compose +compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose-runtime" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-runtime" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose-runtime" } compose-ui-utils = { module = "androidx.compose.ui:ui-util", version.ref = "compose-runtime" } diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt b/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt index 4388254787..8582fd21ed 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt @@ -2,8 +2,6 @@ package com.tangem.lib.auth interface ExpressAuthProvider { - fun getApiKey(): String - fun getUserId(): String fun getSessionId(): String diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/sessionId/ExpressSessionIdGenerator.kt b/libs/auth/src/main/java/com/tangem/lib/auth/sessionId/ExpressSessionIdGenerator.kt deleted file mode 100644 index be7270b4c7..0000000000 --- a/libs/auth/src/main/java/com/tangem/lib/auth/sessionId/ExpressSessionIdGenerator.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.lib.auth.sessionId - -interface ExpressSessionIdGenerator { - - fun generateNewSessionId() -} \ 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 6207c86026..8fb67b9107 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 @@ -74,7 +74,7 @@ internal class DefaultBlockchainSDKFactory( return@launch } - Timber.d("Update BlockchainSDKConfig") + Timber.i("Update BlockchainSDKConfig") configStore.store( value = BlockchainSDKConfigConverter.convert(value = config), @@ -91,7 +91,7 @@ internal class DefaultBlockchainSDKFactory( return@launch } - Timber.d("Update BlockchainProviderTypes") + Timber.i("Update BlockchainProviderTypes") blockchainProviderTypesStore.store( value = BlockchainProviderTypesConverter.convert(response), diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 9ede47a471..e7224f8434 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -27,7 +27,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( ) { fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory { - Timber.d("Create WalletManagerFactory") + Timber.i("Create WalletManagerFactory") return WalletManagerFactory( config = config, diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt index f8e769ebd1..6209d90560 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt @@ -85,6 +85,8 @@ internal object BlockchainSDKConfigConverter : Converter Blockchain.KoinosTestnet "joystream" -> Blockchain.Joystream "bittensor" -> Blockchain.Bittensor + "filecoin" -> Blockchain.Filecoin + "blast" -> Blockchain.Blast + "blast/test" -> Blockchain.BlastTestnet + "cyber" -> Blockchain.Cyber + "cyber/test" -> Blockchain.CyberTestnet else -> null } } @@ -237,9 +242,17 @@ fun Blockchain.toNetworkId(): String { Blockchain.KoinosTestnet -> "koinos/test" Blockchain.Joystream -> "joystream" Blockchain.Bittensor -> "bittensor" + Blockchain.Filecoin -> "filecoin" + Blockchain.Blast -> "blast" + Blockchain.BlastTestnet -> "blast/test" + Blockchain.Cyber -> "cyber" + Blockchain.CyberTestnet -> "cyber/test" } } +/** + * CoinId is id from tangem backend response coin "id" field + */ @Suppress("ComplexMethod", "LongMethod") fun Blockchain.toCoinId(): String { return when (this) { @@ -313,6 +326,9 @@ fun Blockchain.toCoinId(): String { Blockchain.Koinos, Blockchain.KoinosTestnet -> "koinos" Blockchain.Joystream -> "joystream" Blockchain.Bittensor -> "bittensor" + Blockchain.Filecoin -> "filecoin" + Blockchain.Blast, Blockchain.BlastTestnet -> "blast-ethereum" + Blockchain.Cyber, Blockchain.CyberTestnet -> "cyberconnect" } } @@ -325,7 +341,9 @@ fun Blockchain.amountToCreateAccount(token: Token? = null): BigDecimal? { Blockchain.Stellar -> if (token?.symbol == NODL) BigDecimal(NODL_AMOUNT_TO_CREATE_ACCOUNT) else BigDecimal.ONE Blockchain.XRP -> BigDecimal.TEN Blockchain.Near, Blockchain.NearTestnet -> 0.00182.toBigDecimal() - Blockchain.Aptos, Blockchain.AptosTestnet -> BigDecimal.ZERO + Blockchain.Aptos, Blockchain.AptosTestnet, + Blockchain.Filecoin, + -> BigDecimal.ZERO else -> null } } @@ -341,6 +359,4 @@ private val excludedBlockchains = listOf( Blockchain.Unknown, Blockchain.Nexa, Blockchain.NexaTestnet, - Blockchain.Mantle, - Blockchain.MantleTestnet, ) \ No newline at end of file diff --git a/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/loader/BlockchainProvidersResponseLoaderTest.kt b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/loader/BlockchainProvidersResponseLoaderTest.kt index afbf3601ee..142425d558 100644 --- a/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/loader/BlockchainProvidersResponseLoaderTest.kt +++ b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/loader/BlockchainProvidersResponseLoaderTest.kt @@ -7,7 +7,7 @@ import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.adapter import com.tangem.blockchainsdk.BlockchainProvidersResponse -import com.tangem.datasource.api.tangemTech.TangemTechServiceApi +import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.config.models.ProviderModel @@ -24,7 +24,7 @@ import org.junit.Test @OptIn(ExperimentalStdlibApi::class) internal class BlockchainProvidersResponseLoaderTest { - private val tangemTechServiceApi = mockk() + private val tangemTechServiceApi = mockk() private val assetReader = mockk() private val moshi = mockk() private val jsonAdapter = mockk>() diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFee.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFee.kt index a4643460ea..77b655eb1f 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFee.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFee.kt @@ -18,4 +18,10 @@ sealed interface ProxyFee { override val fee: ProxyAmount, val minAdaValue: BigDecimal, ) : ProxyFee + + data class Filecoin( + override val gasLimit: BigInteger, + override val fee: ProxyAmount, + val gasPremium: Long, + ) : ProxyFee } \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt index 1d659a2d09..58ea63d4a6 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt @@ -39,6 +39,7 @@ private fun AppExtension.configureDefaultConfig(project: Project) { testInstrumentationRunner = "com.tangem.common.HiltTestRunner" } + } // TODO: [REDACTED_JIRA] @@ -50,41 +51,45 @@ private fun AppExtension.configureBuildFeatures() { private fun AppExtension.configureBuildTypes() { buildTypes { - BuildType.values().forEach { buildVariant -> - maybeCreate(buildVariant.id).apply { - configureBuildVariant(extension = this@configureBuildTypes, buildVariant) + BuildType.values().forEach { buildType -> + maybeCreate(buildType.id).apply { + configureBuildVariant( + appExtension = this@configureBuildTypes, + buildType = buildType, + ) BuildConfigFieldFactory( - fields = buildVariant.configFields, + fields = buildType.configFields, builder = ::buildConfigField, ).create() } } } + testBuildType = BuildType.Mocked.id } -private fun AndroidBuildType.configureBuildVariant(extension: AppExtension, buildType: BuildType) { +private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, buildType: BuildType) { when (buildType) { BuildType.Release -> { isDebuggable = false isMinifyEnabled = false - proguardFiles(extension.getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + proguardFiles(appExtension.getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") } BuildType.Debug -> { isDebuggable = true isMinifyEnabled = false } - BuildType.External -> { - initWith(extension.buildTypes.getByName(BuildType.Release.id)) - matchingFallbacks.add(BuildType.Release.id) - signingConfig = extension.signingConfigs.getByName(BuildType.Debug.id) - } BuildType.Internal, - BuildType.Mocked, + BuildType.External, -> { - initWith(extension.buildTypes.getByName(BuildType.Release.id)) + initWith(appExtension.buildTypes.getByName(BuildType.Release.id)) matchingFallbacks.add(BuildType.Release.id) - signingConfig = extension.signingConfigs.getByName(BuildType.Debug.id) + signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) + } + BuildType.Mocked -> { + initWith(appExtension.buildTypes.getByName(BuildType.Release.id)) + matchingFallbacks.add(BuildType.Release.id) + signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) isDebuggable = true } } diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index 49a6869c47..fe2f2c147f 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -25,6 +25,7 @@ internal fun BaseExtension.configureCompose(project: Project) { contains(Regex(pattern = ":presentation\$")) || contains(Regex(pattern = ":app\$")) || // TODO: [REDACTED_JIRA] contains(Regex(pattern = ":features:markets:api\$")) || // provides Composable function + contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function contains(Regex(pattern = ":impl\$")) } diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt index c8bfbff0f1..ad8aa17560 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/AppConfig.kt @@ -4,7 +4,7 @@ internal object AppConfig { const val packageName = "com.tangem.wallet" const val versionCode = 1 const val versionName = "1.0.0-SNAPSHOT" - const val minSdkVersion = 23 + const val minSdkVersion = 24 const val targetSdkVersion = 34 const val compileSdkVersion = 34 } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 858b9a3a3f..740ce7ce0c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -224,6 +224,8 @@ include(":domain:staking:models") include(":domain:wallet-connect") include(":domain:markets") include(":domain:markets:models") +include(":domain:manage-tokens") +include(":domain:manage-tokens:models") // endregion Domain modules // region Data modules @@ -246,4 +248,5 @@ include(":data:qr-scanning") include(":data:staking") include(":data:wallet-connect") include(":data:markets") +include(":data:manage-tokens") // endregion Data modules \ No newline at end of file diff --git a/version.properties b/version.properties index f8c3184558..16ef03c0ac 100644 --- a/version.properties +++ b/version.properties @@ -1 +1 @@ -versionName=5.13.0 \ No newline at end of file +versionName=5.14.0 \ No newline at end of file