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..51a162df5c 100644 --- a/app/src/main/assets/testnet_tokens.json +++ b/app/src/main/assets/testnet_tokens.json @@ -688,6 +688,16 @@ "networkId": "base/test" } ] + }, + { + "id": "blast-ethereum", + "name": "Blast", + "symbol": "ETH", + "networks": [ + { + "networkId": "blast/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..5c04f34268 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -32,7 +32,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.domain.walletconnect2.domain.WalletConnectSessionsRepository import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles import com.tangem.tap.proxy.AppStateHolder @@ -82,8 +81,6 @@ interface ApplicationEntryPoint { fun getWalletsRepository(): WalletsRepository - fun getSendFeatureToggles(): SendFeatureToggles - fun getOneTimeEventFilter(): OneTimeEventFilter fun getGeneralUserWalletsListManager(): UserWalletsListManager diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index eaca995355..4e62a55ab3 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -47,7 +47,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 @@ -140,9 +139,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() @@ -271,7 +267,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { appThemeModeRepository = appThemeModeRepository, balanceHidingRepository = balanceHidingRepository, walletsRepository = walletsRepository, - sendFeatureToggles = sendFeatureToggles, generalUserWalletsListManager = generalUserWalletsListManager, wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, 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/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/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/global/GlobalAction.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt index ade5d123b2..614b69566d 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,7 +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 @@ -72,8 +70,6 @@ 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() @@ -93,6 +89,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..043426c2de 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 @@ -92,9 +92,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/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/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/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/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 765e3bcb7f..e96df425a1 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,8 @@ 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.repositories.MarketsTokenRepository import dagger.Module import dagger.Provides @@ -19,4 +21,16 @@ 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) + } } \ 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/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/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..c108d54aab 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 @@ -42,11 +42,11 @@ internal class MainViewModel @Inject constructor( 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() @@ -85,6 +84,8 @@ internal class MainViewModel @Inject constructor( /** Loading the resources needed to run the application */ private fun loadApplicationResources() { viewModelScope.launch(dispatchers.main) { + apiConfigsManager.initialize() + blockchainSDKFactory.init() prepareSelectedWalletFeedback() @@ -130,12 +131,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/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/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/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/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/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..917b2eab45 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,8 +289,8 @@ private fun MarketChartPreview( LaunchedEffect(key1 = Unit) { dataProducer.runTransactionSuspend { chartData = MarketChartData.Data( - x = x, - y = y, + x = x.toImmutableList(), + y = y.toImmutableList(), ) updateLook { it.copy( @@ -338,13 +344,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 +360,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 = if (it.type == MarketChartLook.Type.Growing) { + MarketChartLook.Type.Falling + } else { + 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..2a124ed8d2 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 @@ -44,18 +46,19 @@ fun MarketChartMini( MarketChartLook.Type.Falling -> fallingColor } - 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 +66,6 @@ fun MarketChartMini( model = model, zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), scrollState = rememberVicoScrollState(scrollEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), ) } @@ -74,8 +76,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 { @@ -92,8 +94,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..dc194c3b3b 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() }, ) { 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..8db48deab6 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,22 @@ 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 + } } }, 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 +54,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 +64,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 +110,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/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/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/SwitchBaseUrlInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchBaseUrlInterceptor.kt new file mode 100644 index 0000000000..6da54754b1 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchBaseUrlInterceptor.kt @@ -0,0 +1,43 @@ +package com.tangem.datasource.api.common + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import okio.IOException + +/** + * Switch base url [Interceptor] + * + * @property id api config id [ApiConfig.ID] + * @property apiConfigsManager api configs manager + * +[REDACTED_AUTHOR] + */ +internal class SwitchBaseUrlInterceptor( + 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() + + request = builder + .url(url = request.url.adjustBaseUrl()) + .build() + + return chain.proceed(request) + } + + private fun HttpUrl.adjustBaseUrl(): HttpUrl { + val host = apiConfigsManager.getBaseUrl(id).toHttpUrl().host + + return this.newBuilder() + .host(host) + .build() + } +} \ No newline at end of file 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..7c5b88f27a --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -0,0 +1,50 @@ +package com.tangem.datasource.api.common.config + +/** + * Api config + * + * @property currentEnvironment current api environment + * + * @see API configuration + * +[REDACTED_AUTHOR] + */ +sealed class ApiConfig(open val currentEnvironment: ApiEnvironment) { + + /** Available environments with base url */ + abstract val environments: Map + + /** Unique id */ + val id: ID = initializeId() + + enum class ID { + Express, + TangemTech, + } + + /** Copy method for sealed class [ApiConfig] */ + fun copySealed(currentEnvironment: ApiEnvironment): ApiConfig { + return when (this) { + is Express -> copy(currentEnvironment = currentEnvironment) + is TangemTech -> copy(currentEnvironment = currentEnvironment) + } + } + + private fun initializeId(): ID { + return when (this) { + is Express -> ID.Express + is TangemTech -> ID.TangemTech + } + } + + 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" + + /** All api configs */ + fun values() = listOf(Express(), TangemTech()) + } +} \ 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/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt new file mode 100644 index 0000000000..5ebf06c017 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -0,0 +1,35 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig + +/** + * Express [ApiConfig] + * + * @property currentEnvironment current api environment + */ +internal data class Express( + override val currentEnvironment: ApiEnvironment = initializeCurrentEnvironment(), +) : ApiConfig(currentEnvironment) { + + override val environments: Map = mapOf( + ApiEnvironment.DEV to "[REDACTED_ENV_URL]", + ApiEnvironment.STAGE to "[REDACTED_ENV_URL]", + ApiEnvironment.PROD to "https://express.tangem.com/v1/", + ) + + private companion object { + + fun initializeCurrentEnvironment(): 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/TangemTech.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt new file mode 100644 index 0000000000..25d15d3194 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt @@ -0,0 +1,34 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig + +/** + * TangemTech [ApiConfig] + * + * @property currentEnvironment current api environment + */ +internal data class TangemTech( + override val currentEnvironment: ApiEnvironment = initializeCurrentEnvironment(), +) : ApiConfig(currentEnvironment) { + + override val environments: Map = mapOf( + ApiEnvironment.DEV to "https://devapi.tangem-tech.com/v1/", + ApiEnvironment.PROD to "https://api.tangem-tech.com/v1/", + ) + + private companion object { + + fun initializeCurrentEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + -> ApiEnvironment.DEV + MOCKED_BUILD_TYPE, + 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/managers/ApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt new file mode 100644 index 0000000000..6bb9cbb6ca --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig + +/** + * Api configs manager + * +[REDACTED_AUTHOR] + */ +interface ApiConfigsManager { + + /** Initialize resources */ + suspend fun initialize() {} + + /** Get base url of api by [id] */ + fun getBaseUrl(id: ApiConfig.ID): String +} \ 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..0af38d7ba9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt @@ -0,0 +1,69 @@ +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 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.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Implementation of [ApiConfigsManager] in DEV environment + * + * @property appPreferencesStore app preferences store + * @property dispatchers coroutine dispatcher provider + */ +internal class DevApiConfigsManager( + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : MutableApiConfigsManager { + + override val configs: Flow> get() = _apiConfigs + + private val _apiConfigs = MutableStateFlow(value = ApiConfig.values()) + + 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.value = _apiConfigs.value.map { config -> + val savedEnvironment = savedEnvironments[config.id.name] + + if (savedEnvironment != null) { + config.copySealed(currentEnvironment = savedEnvironment) + } else { + config + } + } + } + .launchIn(CoroutineScope(dispatchers.main)) + } + + override fun getBaseUrl(id: ApiConfig.ID): String { + val config = _apiConfigs.value.firstOrNull { it.id == id } + ?: error("Api config with id [$id] not found. Check ApiConfig implementations") + + return config.environments[config.currentEnvironment] + ?: error( + "Api config with id [$id] doesn't contain environment [${config.currentEnvironment}]. " + + "Check ApiConfig implementations", + ) + } + + 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..87ec3f21fb --- /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 { + + /** Configs */ + 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..9f5e1e31e5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManager.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig + +/** Implementation of [ApiConfigsManager] in PROD environment */ +internal class ProdApiConfigsManager : ApiConfigsManager { + + override fun getBaseUrl(id: ApiConfig.ID): String { + val config = ApiConfig.values().firstOrNull { it.id == id } + ?: error("Api config with id [$id] not found. Check ApiConfig implementations") + + return config.environments[config.currentEnvironment] + ?: error( + "Api config with id [$id] doesn't contain " + + "environment [${config.currentEnvironment}]. Check ApiConfig implementations", + ) + } +} \ 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/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/config/ConfigManagerImpl.kt b/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt index 36bba25d83..408493a3d1 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 @@ -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/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/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 6951732450..19a3e4c6b1 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,6 +3,10 @@ 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.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 @@ -10,12 +14,15 @@ 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.local.preferences.AppPreferencesStore import com.tangem.datasource.utils.RequestHeader import com.tangem.datasource.utils.RequestHeader.* +import com.tangem.datasource.utils.addEnvironmentSwitcher 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.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides @@ -32,6 +39,19 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) class NetworkModule { + @Provides + @Singleton + fun provideApiConfigManager( + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): ApiConfigsManager { + return if (BuildConfig.TESTER_MENU_ENABLED) { + DevApiConfigsManager(appPreferencesStore, dispatchers) + } else { + ProdApiConfigsManager() + } + } + @Provides @Singleton fun provideExpressApi( @@ -39,18 +59,15 @@ class NetworkModule { @ApplicationContext context: Context, expressAuthProvider: ExpressAuthProvider, appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): 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) + .baseUrl(apiConfigsManager.getBaseUrl(id = ApiConfig.ID.Express)) .client( OkHttpClient.Builder() + .addEnvironmentSwitcher(ApiConfig.ID.Express, apiConfigsManager) .addHeaders(Express(expressAuthProvider)) .addHeaders(AppVersionPlatformHeaders(appVersionProvider)) .addLoggers(context) @@ -87,8 +104,15 @@ class NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): TangemTechApi { - return provideTangemTechApiInternal(moshi, context, appVersionProvider, PROD_V1_TANGEM_TECH_BASE_URL) + return provideTangemTechApiInternal( + moshi = moshi, + context = context, + appVersionProvider = appVersionProvider, + apiConfigsManager = apiConfigsManager, + baseUrl = apiConfigsManager.getBaseUrl(id = ApiConfig.ID.TangemTech), + ) } @Provides @@ -97,8 +121,15 @@ class NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): TangemTechApiV2 { - return provideTangemTechApiInternal(moshi, context, appVersionProvider, PROD_V2_TANGEM_TECH_BASE_URL) + return provideTangemTechApiInternal( + moshi = moshi, + context = context, + appVersionProvider = appVersionProvider, + apiConfigsManager = apiConfigsManager, + baseUrl = PROD_V2_TANGEM_TECH_BASE_URL, + ) } @Provides @@ -108,8 +139,15 @@ class NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): TangemTechApi { - return provideTangemTechApiInternal(moshi, context, appVersionProvider, DEV_V1_TANGEM_TECH_BASE_URL) + return provideTangemTechApiInternal( + moshi = moshi, + context = context, + appVersionProvider = appVersionProvider, + apiConfigsManager = apiConfigsManager, + baseUrl = DEV_V1_TANGEM_TECH_BASE_URL, + ) } @Provides @@ -118,12 +156,14 @@ class NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): TangemTechServiceApi { return provideTangemTechApiInternal( moshi = moshi, context = context, appVersionProvider = appVersionProvider, - baseUrl = PROD_V1_TANGEM_TECH_BASE_URL, + apiConfigsManager = apiConfigsManager, + baseUrl = apiConfigsManager.getBaseUrl(id = ApiConfig.ID.TangemTech), timeouts = Timeouts( callTimeoutSeconds = TANGEM_TECH_SERVICE_TIMEOUT_SECONDS, ), @@ -138,11 +178,13 @@ class NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): TangemTechMarketsApi { return provideTangemTechApiInternal( moshi = moshi, context = context, appVersionProvider = appVersionProvider, + apiConfigsManager = apiConfigsManager, baseUrl = DEV_V1_TANGEM_TECH_BASE_URL, timeouts = Timeouts( callTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, @@ -157,11 +199,13 @@ class NetworkModule { moshi: Moshi, context: Context, appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, baseUrl: String, timeouts: Timeouts = Timeouts(), requestHeaders: List = listOf(CacheControlHeader, AppVersionPlatformHeaders(appVersionProvider)), ): T { val client = OkHttpClient.Builder() + .addEnvironmentSwitcher(id = ApiConfig.ID.TangemTech, apiConfigsManager = apiConfigsManager) .let { builder -> var b = builder if (timeouts.callTimeoutSeconds != null) { @@ -204,13 +248,9 @@ class NetworkModule { 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 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/utils/HttpClientExt.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/HttpClientExt.kt index 5490d5f8f3..0f571dd13a 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,6 +3,9 @@ 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.SwitchBaseUrlInterceptor +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 okhttp3.Interceptor import okhttp3.OkHttpClient @@ -25,7 +28,7 @@ internal fun OkHttpClient.Builder.addHeaders(vararg requestHeaders: RequestHeade /** * 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 +39,26 @@ internal fun OkHttpClient.Builder.addLoggers(context: Context? = null): OkHttpCl } else { this } +} + +/** + * Add environment switcher + * + * @param id class of [ApiConfig] + * @param apiConfigsManager api configs manager + */ +internal fun OkHttpClient.Builder.addEnvironmentSwitcher( + id: ApiConfig.ID, + apiConfigsManager: ApiConfigsManager, +): OkHttpClient.Builder { + return if (BuildConfig.TESTER_MENU_ENABLED) { + addInterceptor( + interceptor = SwitchBaseUrlInterceptor( + id = id, + apiConfigsManager = apiConfigsManager, + ), + ) + } else { + this + } } \ 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..bfc7de29cc --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -0,0 +1,77 @@ +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.ApiConfig +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.datasource.api.common.config.Express +import com.tangem.datasource.api.common.config.TangemTech +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** +[REDACTED_AUTHOR] + */ +@RunWith(Parameterized::class) +internal class ProdApiConfigsManagerTest(private val model: Model) { + + private val manager = ProdApiConfigsManager() + + @Test + fun test_getBaseUrl() { + val actual = manager.getBaseUrl(id = model.id) + + Truth.assertThat(actual).isEqualTo(model.expected) + } + + data class Model(val id: ApiConfig.ID, val expected: String) + + private companion object { + + @JvmStatic + @Parameterized.Parameters + fun data(): Collection = ApiConfig.values().map { + when (it) { + is Express -> createExpressModel() + is TangemTech -> createTangemTechModel() + } + } + + private fun createExpressModel(): Model { + return Model( + id = ApiConfig.ID.Express, + expected = 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}]") + }, + ) + } + + private fun createTangemTechModel(): Model { + return Model( + id = ApiConfig.ID.TangemTech, + expected = when (BuildConfig.BUILD_TYPE) { + DEBUG_BUILD_TYPE, + INTERNAL_BUILD_TYPE, + -> "https://devapi.tangem-tech.com/v1/" + MOCKED_BUILD_TYPE, + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> "https://api.tangem-tech.com/v1/" + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + }, + ) + } + } +} \ No newline at end of file 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 7def5136f7..c3f822ed71 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": "5.13.0" @@ -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/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-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index b198f3eee7..acd0b259b8 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -48,7 +48,7 @@ 生物 購買 您尚未授予相機訪問權限,請更改您的隱私設置 - 刪除 + 删除 關閉 繼續 複製 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..9af3516af2 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 @@ -15,7 +15,7 @@ import com.tangem.core.ui.res.TangemTheme @Composable internal fun InputRowImageBase( subtitle: TextReference, - caption: TextReference, + caption: TextReference?, imageUrl: String, modifier: Modifier = Modifier, subtitleColor: Color = TangemTheme.colors.text.primary1, @@ -40,12 +40,14 @@ internal fun InputRowImageBase( 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() } 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/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/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index 33a8bd032e..a1613fcb79 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,13 @@ 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 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getActiveIconResByNetworkId(networkId: String): Int { return when (networkId) { @@ -141,6 +143,8 @@ 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 else -> R.drawable.ic_alert_24 } } @@ -209,11 +213,13 @@ 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 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getGreyedOutIconRes(blockchainId: String): Int { return when (blockchainId) { @@ -280,11 +286,13 @@ 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 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getGreyedOutIconResByNetworkId(networkId: String): Int { return when (networkId) { @@ -351,6 +359,8 @@ 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 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..40e51a085c 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 @@ -117,6 +117,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,8 +125,8 @@ 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 } @@ -250,40 +251,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 +281,53 @@ 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, + locale: Locale = Locale.getDefault(), + threeDigitsMethod: Boolean = false, + scale: Int = 0, + ): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return BigDecimalFormatterCompat.formatCompactAmountNoLocaleContext(amount = amount) + } + + 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.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD } \ 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..7c6fa15b74 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 @@ -61,7 +61,14 @@ object DateTimeFormatters { */ val dateMMMMd: DateTimeFormatter by lazy { DateTimeFormatterBuilder() - .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMM d")) + .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "dd MMM")) + .toFormatter() + .withLocale(Locale.getDefault()) + } + + val dateYYYY: DateTimeFormatter by lazy { + DateTimeFormatterBuilder() + .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "yyyy")) .toFormatter() .withLocale(Locale.getDefault()) } 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_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_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..b3dfcacad4 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_staking_banner.xml @@ -0,0 +1,345 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index be83e51fa2..e670fce633 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 @@ -10,6 +10,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( @@ -19,6 +20,7 @@ internal class DefaultMarketsTokenRepository( ) : MarketsTokenRepository { private val tokenListConverter = TokenMarketListConverter() + private val tokenChartConverter = TokenChartConverter() private fun createTokenMarketsFetcher(firstBatchSize: Int, nextBatchSize: Int) = LimitOffsetBatchFetcher( prefetchDistance = firstBatchSize, @@ -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,7 +59,7 @@ 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 @@ -81,12 +83,42 @@ 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()) + } } \ 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..0da0fcd457 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,6 +1,6 @@ package com.tangem.data.markets -import com.tangem.data.markets.converters.TokenListChartConverter +import com.tangem.data.markets.converters.TokenChartConverter import com.tangem.data.markets.converters.TokenMarketChartsConverter import com.tangem.data.markets.converters.TokenQuotesConverter import com.tangem.data.markets.converters.toRequestParam @@ -21,7 +21,7 @@ internal class MarketsBatchUpdateFetcher( private val tangemTechApi: TangemTechApi, ) : BatchUpdateFetcher, TokenMarketUpdateRequest> { - private val tokenListChartsConverter = TokenMarketChartsConverter(TokenListChartConverter()) + private val tokenListChartsConverter = TokenMarketChartsConverter(TokenChartConverter()) private val tokenQuotesConverter = TokenQuotesConverter() override suspend fun BatchUpdateFetcher.UpdateContext>.fetchUpdateAsync( 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 84% 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..bdd90f2733 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 { +class 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..eee63ef4e9 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 @@ -20,7 +20,7 @@ fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { 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..88eccb1163 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,38 @@ 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, + private val tokenChartConverter: TokenChartConverter, ) { 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..8693393e68 --- /dev/null +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt @@ -0,0 +1,115 @@ +package com.tangem.data.markets.converters + +import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.utils.converter.Converter + +internal class TokenMarketInfoConverter : Converter { + + override fun convert(value: TokenMarketInfoResponse): TokenMarketInfo { + return with(value) { + TokenMarketInfo( + id = id, + name = name, + symbol = symbol, + currentPrice = currentPrice, + priceChangePercentage = priceChangePercentage?.convert(), + networks = networks?.convert(), + shortDescription = shortDescription, + fullDescription = fullDescription, + insights = insights?.convert(), + metrics = metrics?.convert(), + links = links?.convert(), + pricePerformance = pricePerformance?.convert(), + ) + } + } + + private fun TokenMarketInfoResponse.PriceChangePercentage.convert(): TokenMarketInfo.PriceChangePercentage { + return TokenMarketInfo.PriceChangePercentage( + day = day, + week = week, + month = month, + threeMonths = threeMonths, + sixMonths = sixMonths, + year = year, + allTime = allTime, + ) + } + + @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/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..c5748f49a8 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,10 @@ package com.tangem.data.staking +import android.util.Base64 import arrow.core.raise.catch import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toCoinId +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 +25,10 @@ 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.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,10 +42,11 @@ 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 @@ -55,6 +61,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() @@ -129,7 +136,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, ) @@ -160,12 +167,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 +199,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), ) } @@ -435,12 +478,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 +503,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) { @@ -502,23 +569,24 @@ internal class DefaultStakingRepository( 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" + // 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.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.BSC.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, ) } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt index ddf093c628..f19e97319c 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldConverter.kt @@ -39,14 +39,18 @@ class YieldConverter( private fun convertEnter(enterDTO: YieldDTO.ArgsDTO.Enter): Yield.Args.Enter { return Yield.Args.Enter( addresses = convertAddresses(enterDTO.addresses), - args = enterDTO.args.mapValues { convertAddressArgument(it.value) }, + args = enterDTO.args + .mapKeys { convertArgType(it.key) } + .mapValues { convertAddressArgument(it.value) }, ) } private fun convertAddresses(addressesDTO: YieldDTO.ArgsDTO.Enter.Addresses): Yield.Args.Enter.Addresses { return Yield.Args.Enter.Addresses( address = convertAddressArgument(addressesDTO.address), - additionalAddresses = addressesDTO.additionalAddresses?.mapValues { convertAddressArgument(it.value) }, + additionalAddresses = addressesDTO.additionalAddresses + ?.mapKeys { convertArgType(it.key) } + ?.mapValues { convertAddressArgument(it.value) }, ) } @@ -122,4 +126,12 @@ class YieldConverter( else -> Yield.RewardType.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/repository/DefaultNetworksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt index bcc65da983..58aa0a6dd6 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 @@ -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/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..f69e364662 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/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/TokenMarketInfo.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt new file mode 100644 index 0000000000..e1cf490632 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt @@ -0,0 +1,81 @@ +package com.tangem.domain.markets + +import java.math.BigDecimal + +data class TokenMarketInfo( + val id: String, + val name: String, + val symbol: String, + val currentPrice: BigDecimal, + val priceChangePercentage: PriceChangePercentage?, + val networks: List?, + val shortDescription: String?, + val fullDescription: String?, + val insights: Insights?, + val metrics: Metrics?, + val links: Links?, + val pricePerformance: PricePerformance?, +) { + data class PriceChangePercentage( + val day: BigDecimal?, + val week: BigDecimal?, + val month: BigDecimal?, + val threeMonths: BigDecimal?, + val sixMonths: BigDecimal?, + val year: BigDecimal?, + val allTime: BigDecimal?, + ) + + 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/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/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index 31693705dc..e6e3a23e03 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,8 @@ 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 } \ No newline at end of file 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/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..f30b65fcf4 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 @@ -113,6 +119,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/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/GetStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt index 02abd643a4..cfe2c9a254 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,14 +18,20 @@ 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 createdTransaction = createAction.transactions + ?.get(createAction.currentStepIndex) + ?: error("No available transaction to patch") val patchedTransaction = stakingRepository.constructTransaction(createdTransaction.id) patchedTransaction 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..5605958156 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 @@ -61,9 +61,9 @@ 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 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..75e83715bb 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 @@ -167,7 +167,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 +186,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( 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..0078651a11 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt @@ -0,0 +1,162 @@ +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, + ) + } + } + } + + val activeChild = stackState.value.active.configuration + + 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(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(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..4e978ed5ee --- /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 = tokenQuotes.currentPrice, + h24Percent = tokenQuotes.h24Percent(), + weekPercent = tokenQuotes.weekPercent(), + monthPercent = tokenQuotes.monthPercent(), + ), + 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..5bc189be06 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -0,0 +1,53 @@ +package com.tangem.features.markets.details.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +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.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, + ) { + val state by model.state.collectAsStateWithLifecycle() + + 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..2359614915 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -0,0 +1,384 @@ +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.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetTokenMarketInfoUseCase +import com.tangem.domain.markets.GetTokenPriceChartUseCase +import com.tangem.domain.markets.PriceChangeInterval +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.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.flow.* +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode +import javax.inject.Inject + +@Suppress("LargeClass") +@Stable +internal class MarketsTokenDetailsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val urlOpener: UrlOpener, +) : Model() { + + 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 { + it.copy( + type = getChartTypeByPercent(params.token.tokenQuotes.h24Percent), + xAxisFormatter = { value -> + value.toLong().toTimeFormat(DateTimeFormatters.timeFormatter) + }, + yAxisFormatter = { value -> + BigDecimalFormatter.formatFiatAmountUncapped( + fiatAmount = value, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = "", + ) + }, + ) + } + } + + val state = MutableStateFlow( + MarketsTokenDetailsUM( + tokenName = params.token.name, + priceText = BigDecimalFormatter.formatFiatAmountUncapped( + 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, + body = MarketsTokenDetailsUM.Body.Loading, + 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() { + loadChart(state.value.selectedInterval) + loadInfo() + } + + private fun onSelectedIntervalChange(interval: PriceChangeInterval) { + if (state.value.selectedInterval == interval) return + + state.update { + it.copy( + selectedInterval = interval, + priceChangeType = PriceChangeType.UP, + ) + } + + loadChart(interval) + } + + 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 = getFormatterByInterval(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, + ) + } + } + + 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 -> + state.update { + it.copy( + body = MarketsTokenDetailsUM.Body.Content( + description = descriptionConverter.convert(result), + infoBlocks = infoConverter.convert(result), + ), + ) + } + }, + 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 fun getFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String { + return when (interval) { + PriceChangeInterval.H24 -> { value: BigDecimal -> + value.toLong().toTimeFormat(DateTimeFormatters.timeFormatter) + } + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + -> { value -> + value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd) + } + PriceChangeInterval.YEAR -> { value -> + value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd) + } + PriceChangeInterval.ALL_TIME -> { value -> + value.toLong().toTimeFormat(DateTimeFormatters.dateYYYY) + } + } + } + + @Suppress("MagicNumber") + private fun onMarkerPointSelected(time: BigDecimal?, price: BigDecimal?) { + val timeText = time?.toLong()?.toTimeFormat(DateTimeFormatters.dateTimeFormatter)?.let { + resourceReference(R.string.common_range, wrappedList(it, resourceReference(R.string.common_now))) + } ?: resourceReference(R.string.common_today) + + val percent = price?.subtract(params.token.tokenQuotes.currentPrice) + ?.divide(params.token.tokenQuotes.currentPrice, 4, RoundingMode.HALF_UP) + ?.multiply(BigDecimal(-100)) + ?: params.token.tokenQuotes.h24Percent + + val percentText = BigDecimalFormatter.formatPercent( + percent = percent, + useAbsoluteValue = true, + ) + + state.update { + it.copy( + dateTimeText = timeText, + priceText = BigDecimalFormatter.formatFiatAmountUncapped( + fiatAmount = price ?: params.token.tokenQuotes.currentPrice, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ), + priceChangePercentText = percentText, + priceChangeType = when { + percent < BigDecimal.ZERO -> PriceChangeType.DOWN + percent > BigDecimal.ZERO -> PriceChangeType.UP + else -> PriceChangeType.NEUTRAL + }, + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + } + + private fun getChartTypeByPercent(percent: BigDecimal): MarketChartLook.Type { + return if (percent >= BigDecimal.ZERO) { + MarketChartLook.Type.Growing + } else { + MarketChartLook.Type.Falling + } + } + + 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() + } + } +} \ 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..e90f915acd --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt @@ -0,0 +1,131 @@ +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.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, + ), + ) + } + } + + 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(), + 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), + 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(), + 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(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_liquidity), + body = resourceReference(R.string.markets_token_details_liquidity_description), + ), + ) + }, + ), + ) + } + + 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..b08b518ba0 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt @@ -0,0 +1,58 @@ +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.formatCompactFiatAmount( + amount = 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/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..7f8dfd5def --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -0,0 +1,252 @@ +package com.tangem.features.markets.details.impl.ui + +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.Composable +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.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 { + Text( + text = state.priceText, + style = TangemTheme.typography.head, + color = TangemTheme.colors.text.primary1, + ) + 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.size(TangemTheme.dimens.size48), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + } +} + +@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 = "Price", + dateTimeText = stringReference("Date Time"), + priceChangePercentText = "Price Change", + 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, + ), + ), + 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..09d8317df5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoPoint.kt @@ -0,0 +1,136 @@ +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.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, + ) + } + Text( + text = infoPointUM.value, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@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 = { }, + ), + ) + } + } +} + +@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, + ) + } + }, + 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..3cd88bc115 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt @@ -0,0 +1,198 @@ +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.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 = { + Text( + modifier = Modifier, + text = stringResource(R.string.markets_token_details_insights), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + 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", + ), + ), + ), + ) + } +} + +@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..b19876af06 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -0,0 +1,79 @@ +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 chartState = rememberMarketChartState( + dataProducer = state.dataProducer, + colorMapper = { + when (it) { + MarketChartLook.Type.Growing -> growingColor + MarketChartLook.Type.Falling -> fallingColor + } + }, + 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..cb3098ca60 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt @@ -0,0 +1,248 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.animation.* +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..5ef1b1ad91 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoPointUM.kt @@ -0,0 +1,9 @@ +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 onInfoClick: (() -> Unit)? = null, +) \ 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..f7d33a8ddc --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt @@ -0,0 +1,9 @@ +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, +) \ 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..42f7c3e9e0 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt @@ -0,0 +1,68 @@ +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.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 chartState: ChartState, + val onSelectedIntervalChange: (PriceChangeInterval) -> Unit, + val infoBottomSheet: TangemBottomSheetConfig, + 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..167b03fd6c 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 } @@ -47,8 +51,8 @@ internal class MarketsListModel @Inject constructor( 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 +63,7 @@ internal class MarketsListModel @Inject constructor( modelScope = modelScope, dispatchers = dispatchers, ) + private val searchMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, @@ -72,7 +77,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 +179,7 @@ internal class MarketsListModel @Inject constructor( } .distinctUntilChanged() .collectLatest { visibleBatchKeys -> + // TODO load batch on scroll heat area activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval) } } @@ -189,9 +200,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 +224,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 84% 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..590aae7f3f 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(), ) } @@ -67,10 +70,11 @@ 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, ) } @@ -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(), ), ) } @@ -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 62% 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..806d5434cb 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,12 +7,14 @@ 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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* @Stable @@ -20,6 +22,7 @@ internal class MarketsListUMStateManager( private val onLoadMoreUiItems: () -> Unit, private val visibleItemsChanged: (itemsKeys: List) -> Unit, private val onRetryButtonClicked: () -> Unit, + private val onTokenClick: (MarketsListItemUM) -> Unit, ) { private var sortByBottomSheetIsShown @@ -98,21 +101,74 @@ 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 + } + } + + return currentState.copy( + list = ListUM.Content( + items = items, + 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, + ), + ) + } + + 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 98% 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..fa7b373811 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 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 89% 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..020dd5b37a 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,7 +17,7 @@ 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, 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/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/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/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/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..ce7d7eecb5 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,23 @@ sealed class InnerYieldBalanceState { val rewardsCrypto: String, val rewardsFiat: String, val isRewardsToClaim: Boolean, - val balance: List, + 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 +39,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..525eed8a61 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( 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..2556ac4e2a 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,12 @@ 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.utils.transformer.Transformer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,20 +24,26 @@ internal class StakingStateController @Inject constructor() { val uiState: StateFlow get() = mutableUiState.asStateFlow() + private val buttonsTransformer = SetButtonsStateTransformer() + fun update(function: (StakingUiState) -> StakingUiState) { mutableUiState.update(function = function) + mutableUiState.update(function = buttonsTransformer::transform) } fun update(transformer: Transformer) { mutableUiState.update(function = transformer::transform) + mutableUiState.update(function = buttonsTransformer::transform) } fun clear() { mutableUiState.update { getInitialState() } + mutableUiState.update(function = buttonsTransformer::transform) } private fun getInitialState(): StakingUiState { return StakingUiState( + title = TextReference.EMPTY, clickIntents = StakingClickIntentsStub, cryptoCurrencyName = "", currentStep = StakingStep.InitialInfo, @@ -44,7 +54,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..98b09f5e78 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,7 @@ internal sealed class StakingStates { val notifications: ImmutableList, val footerText: String, val transactionDoneState: TransactionDoneState, + val pendingActionInProgress: PendingAction? = null, ) : ConfirmationState() data class Empty( @@ -108,11 +102,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/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index 23141275d2..85ad364a40 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 @@ -10,7 +10,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 @@ -59,15 +58,18 @@ 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, ) } } + .toPersistentList() private fun List.mapBalances(): List { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() @@ -111,31 +113,37 @@ internal class YieldBalancesConverter( } 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/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index cca20b1416..6432bbbea7 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,12 @@ internal object InitialStakingStatePreview { rewardsFiat = "100 $", rewardsCrypto = "100 SOL", isRewardsToClaim = false, - balance = listOf( + 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..3036764d3d 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,8 +38,6 @@ object StakingClickIntentsStub : StakingClickIntents { override fun openRewardsValidators() {} - override fun selectRewardValidator(rewardValue: String) {} - override fun onExploreClick() {} override fun onShareClick() {} 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..4332347169 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -0,0 +1,232 @@ +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_next) + } + } + + StakingStep.Confirmation -> { + if (confirmationState is StakingStates.ConfirmationState.Data) { + if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { + resourceReference(R.string.common_close) + } else { + when (actionType) { + StakingActionCommonType.ENTER -> 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) + } + } + StakingStep.Validators -> resourceReference(R.string.common_continue) + StakingStep.Amount, + StakingStep.RewardsValidators, + -> resourceReference(R.string.common_next) + } + } + + 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 -> { + if (confirmationState is StakingStates.ConfirmationState.Data) { + if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { + clickIntents.onBackClick() + } else { + clickIntents.onActionClick(confirmationState.pendingActions.firstOrNull()) + } + } else { + clickIntents.onBackClick() + } + } + StakingStep.RewardsValidators -> Unit + } + } + + 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/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 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/ui/StakingClaimRewardsValidatorContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt index 64a2f51b56..6657db14f0 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 @@ -63,7 +63,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..476c27a1ca 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) + .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..51bb3380b1 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,14 @@ 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.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.LazyColumn 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 +16,119 @@ 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.* 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, + 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( - modifier = Modifier - .background( - color = TangemTheme.colors.background.primary, - shape = RoundedCornerShape(TangemTheme.dimens.radius12), - ) - .padding(TangemTheme.dimens.spacing12) - .fillMaxWidth(), +private fun BannerBlock(onClick: () -> Unit) { + Box( + modifier = Modifier.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(), + 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, + ) } } @@ -160,7 +155,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 }, textColor = textColor, modifier = Modifier .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -168,13 +163,14 @@ private fun StakingRewardBlock( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), + enabled = isRewardsToClaim, 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), ) { @@ -192,18 +188,20 @@ private fun ActiveStakingBlock(groups: List, onClick: (Bala ) { group.items.forEachIndexed { index, balance -> key(balance.validator.address) { - val caption = combinedReference( - if (group.type == BalanceGroupType.UNSTAKED) { - resourceReference(R.string.staking_details_unbonding_period) + val caption = if (group.type == BalanceType.UNSTAKING) { + combinedReference( + 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) + }, + ) + } else { + combinedReference( + resourceReference(R.string.app_name), annotatedReference { appendSpace() appendColored( @@ -213,20 +211,21 @@ private fun ActiveStakingBlock(groups: List, onClick: (Bala ), color = TangemTheme.colors.text.accent, ) - } - }, - ) + }, + ) + } InputRowImageInfo( title = group.title.takeIf { index == 0 }, subtitle = stringReference(balance.validator.name), caption = caption, - isGrayscaleImage = group.type == BalanceGroupType.UNSTAKED, + isGrayscaleImage = group.type == BalanceType.UNSTAKING, infoTitle = balance.fiatAmount, infoSubtitle = balance.cryptoAmount, imageUrl = balance.validator.image.orEmpty(), modifier = Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), + enabled = group.isClickable, onClick = { onClick(balance) }, ), ) @@ -239,6 +238,11 @@ private fun ActiveStakingBlock(groups: List, onClick: (Bala } } +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..d3c1bda086 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 @@ -13,8 +13,10 @@ 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.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,7 +31,7 @@ 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) @@ -45,8 +47,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) } @@ -68,7 +75,7 @@ private fun SendAppBar(uiState: StakingUiState) { StakingStep.RewardsValidators, StakingStep.Validators, StakingStep.Confirmation, - -> stringResource(id = R.string.common_stake) + -> uiState.title.resolveReference() } val backIcon = when (uiState.currentStep) { StakingStep.Amount, @@ -155,7 +162,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/block/NotificationsBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/NotificationsBlock.kt index 68d638aaaa..a0c3d67869 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,23 @@ package com.tangem.features.staking.impl.presentation.ui.block import androidx.compose.runtime.Composable +import androidx.compose.runtime.key import com.tangem.core.ui.components.notifications.Notification 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) { + 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..c552ca2fbc 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 @@ -39,7 +39,7 @@ internal fun StakingFeeBlock(feeState: FeeState) { ) { Text( text = stringResource(R.string.common_network_fee_title), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.secondary, ) 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..3734e90f19 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,8 +34,6 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun openRewardsValidators() - fun selectRewardValidator(rewardValue: String) - fun onActiveStake(activeStake: BalanceState) fun onExploreClick() 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..a5fd8c4d07 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 @@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase 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.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -62,6 +63,7 @@ internal class StakingViewModel @Inject constructor( private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, private val submitHashUseCase: SubmitHashUseCase, private val isStakeMoreAvailableUseCase: IsStakeMoreAvailableUseCase, + private val stakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver, StakingClickIntents { @@ -100,28 +102,36 @@ 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) } } - 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 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"), @@ -129,8 +139,8 @@ internal class StakingViewModel @Inject constructor( ?: 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) @@ -141,7 +151,7 @@ internal class StakingViewModel @Inject constructor( transactionId = stakingTransaction.id, gasEstimate = stakingTransaction.gasEstimate ?: error("No gas estimate available"), txData = TransactionData.Compiled(value = it.hexToBytes()), - pendingActions = pendingActions, + pendingActionList = confirmationState.pendingActions, ) } ?: error("No unsigned transaction available") } @@ -156,21 +166,31 @@ 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 pendingAction = pendingActions.firstOrNull() val stakingGasEstimate = estimateGasUseCase( + 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"), address = cryptoCurrencyValue.networkAddress?.defaultAddress?.value ?: error("No available address"), - validatorAddress = yield.validators.getOrNull(0)?.address ?: error("No available validator"), + validatorAddress = validatorState.chosenValidator.address, token = yield.token, - passthrough = pendingActions.firstOrNull()?.passthrough, - type = pendingActions.firstOrNull()?.type, + passthrough = pendingAction?.passthrough, + type = pendingAction?.type, ), - ).getOrElse { error("Can't get fee info") } + ).getOrElse { + stateController.update(AddStakingErrorTransformer(it)) + return@launch + } stateController.update( SetConfirmationStateAssentTransformer( @@ -187,6 +207,10 @@ internal class StakingViewModel @Inject constructor( stakingStateRouter.onPrevClick() } + override fun onInitialInfoBannerClick() { + innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL) + } + override fun onInfoClick(infoType: InfoType) { stateController.update( ShowInfoBottomSheetStateTransformer(infoType) { @@ -196,7 +220,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 +228,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 +241,17 @@ 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 onExploreClick() { @@ -249,7 +265,7 @@ internal class StakingViewModel @Inject constructor( } override fun onShareClick() { - // TODO staking analytics event + // TODO add hash to clipboard and send analytics event } fun setRouter(router: InnerStakingRouter, stateRouter: StakingStateRouter) { @@ -317,7 +333,7 @@ internal class StakingViewModel @Inject constructor( transactionId: String, gasEstimate: StakingGasEstimate, txData: TransactionData, - pendingActions: ImmutableList, + pendingActionList: ImmutableList, ) { sendTransactionUseCase( txData = txData, @@ -331,14 +347,14 @@ internal class StakingViewModel @Inject constructor( appCurrencyProvider = Provider { appCurrency }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, 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, @@ -371,17 +387,26 @@ 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 + companion object { + const val WHAT_IS_STAKING_ARTICLE_URL = "TODO staking" } } \ No newline at end of file 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/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..184a6886ca 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 @@ -888,6 +888,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 +1594,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 +1607,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 +1650,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 +1706,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 +1719,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 +1751,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 +1799,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/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..a11c9dbac0 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/state/EnvironmentTogglesScreenUM.kt @@ -0,0 +1,37 @@ +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 + * @property onApplyChangesClick the lambda to be invoked when apply changes button is pressed + */ +internal data class EnvironmentTogglesScreenUM( + @StringRes val title: Int, + val apiInfoList: ImmutableSet, + val onEnvironmentSelect: (id: String, environment: String) -> Unit, + val onBackClick: () -> Unit, + val onApplyChangesClick: () -> 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..d26480798f --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt @@ -0,0 +1,181 @@ +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.PrimaryButton +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 + }, + ), + ) + } + + item { + PrimaryButton( + text = stringResource(id = R.string.apply_changes), + onClick = uiModel.onApplyChangesClick, + modifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing16), + ) + } + } +} + +@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 = {}, + onApplyChangesClick = {}, + ), + ) + } +} \ 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..1b4b896c3e --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt @@ -0,0 +1,95 @@ +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, onApplyChangesClick = router::back) + } + } + + private fun subscribeOnApiConfigs() { + mutableApiConfigsManager.configs + .onEach { configs -> + _uiState.update { + it.copy(apiInfoList = configs.toUiModel()) + } + } + .launchIn(viewModelScope) + } + + private fun List.toUiModel(): ImmutableSet { + return map { config -> + EnvironmentTogglesScreenUM.ApiInfoUM( + name = config.id.name, + select = config.currentEnvironment.name, + url = config.environments[config.currentEnvironment] + ?: error("Current environment's url isn't found"), + environments = config.environments + .map { environment -> environment.key.name } + .toImmutableSet(), + ) + } + .toImmutableSet() + } + + private fun getInitialState(): EnvironmentTogglesScreenUM { + return EnvironmentTogglesScreenUM( + title = R.string.environment_toggles, + apiInfoList = persistentSetOf(), + onEnvironmentSelect = ::onToggleValueChange, + onBackClick = {}, + onApplyChangesClick = {}, + ) + } + + 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/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/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index bffe54c894..67e0caa285 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 @@ -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/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/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/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/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt index c6b5eb1cff..9ec62dbe52 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 @@ -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/gradle/dependencies.toml b/gradle/dependencies.toml index 13ed958b5f..e7873d004f 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-732" +tangemBlockchainSdk = "develop-733" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "release-app_5.13-376" +tangemCardSdk = "develop-375" #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/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 else -> null } } @@ -237,6 +240,9 @@ fun Blockchain.toNetworkId(): String { Blockchain.KoinosTestnet -> "koinos/test" Blockchain.Joystream -> "joystream" Blockchain.Bittensor -> "bittensor" + Blockchain.Filecoin -> "filecoin" + Blockchain.Blast -> "blast" + Blockchain.BlastTestnet -> "blast/test" } } @@ -313,6 +319,8 @@ fun Blockchain.toCoinId(): String { Blockchain.Koinos, Blockchain.KoinosTestnet -> "koinos" Blockchain.Joystream -> "joystream" Blockchain.Bittensor -> "bittensor" + Blockchain.Filecoin -> "filecoin" + Blockchain.Blast, Blockchain.BlastTestnet -> "blast" } } @@ -325,7 +333,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 +351,5 @@ private val excludedBlockchains = listOf( Blockchain.Unknown, Blockchain.Nexa, Blockchain.NexaTestnet, - Blockchain.Mantle, - Blockchain.MantleTestnet, + Blockchain.Filecoin, ) \ No newline at end of file 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/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/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