diff --git a/app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/ScanEmptyTest.kt similarity index 100% rename from app/src/androidTest/kotlin/com/tangem/tests/ScanErrorTest.kt rename to app/src/androidTest/kotlin/com/tangem/tests/ScanEmptyTest.kt diff --git a/app/src/main/assets/testnet_tokens.json b/app/src/main/assets/testnet_tokens.json index 78da3c2c81..409a6b6067 100644 --- a/app/src/main/assets/testnet_tokens.json +++ b/app/src/main/assets/testnet_tokens.json @@ -688,6 +688,26 @@ "networkId": "base/test" } ] + }, + { + "id": "blast-ethereum", + "name": "Blast", + "symbol": "ETH", + "networks": [ + { + "networkId": "blast/test" + } + ] + }, + { + "id": "cyberconnect", + "name": "Cyber", + "symbol": "ETH", + "networks": [ + { + "networkId": "cyber/test" + } + ] } ] } diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 967026bf4c..ff4810cfc6 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -32,7 +32,7 @@ import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles +import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles import com.tangem.tap.proxy.AppStateHolder @@ -82,8 +82,6 @@ interface ApplicationEntryPoint { fun getWalletsRepository(): WalletsRepository - fun getSendFeatureToggles(): SendFeatureToggles - fun getOneTimeEventFilter(): OneTimeEventFilter fun getGeneralUserWalletsListManager(): UserWalletsListManager @@ -119,4 +117,6 @@ interface ApplicationEntryPoint { fun getAppRouter(): AppRouter fun getPushNotificationsFeatureToggles(): PushNotificationsFeatureToggles + + fun getTangemAppLogger(): TangemAppLoggerInitializer } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt index 8758b045e3..18f3cff503 100644 --- a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt +++ b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt @@ -35,7 +35,7 @@ internal class LockUserWalletsTimer( val wasApplicationStopped = settingsRepository.wasApplicationStopped() val shouldOpenWelcomeScreenOnResume = settingsRepository.shouldOpenWelcomeScreenOnResume() - Timber.d( + Timber.i( """ Owner resumed |- Was stopped: $wasApplicationStopped @@ -55,7 +55,7 @@ internal class LockUserWalletsTimer( } override fun onStop(owner: LifecycleOwner) { - Timber.d("Owner stopped") + Timber.i("Owner stopped") owner.lifecycleScope.launch { settingsRepository.setWasApplicationStopped(value = true) @@ -63,13 +63,13 @@ internal class LockUserWalletsTimer( } override fun onDestroy(owner: LifecycleOwner) { - Timber.d("Owner destroyed") + Timber.i("Owner destroyed") stop() } fun restart() { if (delayJob == null) return - Timber.d( + Timber.i( """ Timer restart |- Duration millis: ${duration.inWholeMilliseconds} @@ -80,7 +80,7 @@ internal class LockUserWalletsTimer( private fun start(log: Boolean = true) { if (log) { - Timber.d( + Timber.i( """ Timer start |- Duration millis: ${duration.inWholeMilliseconds} @@ -92,7 +92,7 @@ internal class LockUserWalletsTimer( private fun stop(log: Boolean = true) { if (log) { - Timber.d( + Timber.i( """ Timer stop |- Was started: ${delayJob?.isActive ?: false} @@ -114,7 +114,7 @@ internal class LockUserWalletsTimer( val currentTime = System.currentTimeMillis() val wasApplicationStopped = settingsRepository.wasApplicationStopped() - Timber.d( + Timber.i( """ Finished |- App is stopped: $wasApplicationStopped diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index eaca995355..42280203ef 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -6,8 +6,6 @@ import android.content.pm.PackageManager import coil.ImageLoader import coil.ImageLoaderFactory import com.chuckerteam.chucker.api.ChuckerInterceptor -import com.orhanobut.logger.AndroidLogAdapter -import com.orhanobut.logger.Logger import com.tangem.Log import com.tangem.LogFormat import com.tangem.TangemSdkLogger @@ -47,7 +45,6 @@ import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler @@ -55,12 +52,11 @@ import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandle import com.tangem.tap.common.feedback.AdditionalFeedbackInfo import com.tangem.tap.common.feedback.LegacyFeedbackManager import com.tangem.tap.common.images.createCoilImageLoader +import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.common.log.TangemLogCollector -import com.tangem.tap.common.log.TimberFormatStrategy import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.appReducer import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager import com.tangem.tap.domain.tasks.product.DerivationsFinder import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository import com.tangem.tap.features.customtoken.api.featuretoggles.CustomTokenFeatureToggles @@ -71,7 +67,6 @@ import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints import kotlinx.coroutines.runBlocking import org.rekotlin.Store -import timber.log.Timber import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository lateinit var store: Store @@ -140,9 +135,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { private val walletsRepository: WalletsRepository get() = entryPoint.getWalletsRepository() - private val sendFeatureToggles: SendFeatureToggles - get() = entryPoint.getSendFeatureToggles() - private val oneTimeEventFilter: OneTimeEventFilter get() = entryPoint.getOneTimeEventFilter() @@ -196,6 +188,9 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { private val pushNotificationsFeatureToggles: PushNotificationsFeatureToggles get() = entryPoint.getPushNotificationsFeatureToggles() + + private val tangemAppLoggerInitializer: TangemAppLoggerInitializer + get() = entryPoint.getTangemAppLogger() // endregion override fun onCreate() { @@ -207,16 +202,7 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { fun init() { store = createReduxStore() - if (BuildConfig.LOG_ENABLED) { - Logger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) - Timber.plant( - object : Timber.DebugTree() { - override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - Logger.log(priority, tag, message, t) - } - }, - ) - } + tangemAppLoggerInitializer.initialize() foregroundActivityObserver = ForegroundActivityObserver() activityResultCaller = foregroundActivityObserver @@ -232,8 +218,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { ) } - initWarningMessagesManager() - loadNativeLibraries() if (LogConfig.network.blockchainSdkNetwork) { @@ -271,7 +255,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { appThemeModeRepository = appThemeModeRepository, balanceHidingRepository = balanceHidingRepository, walletsRepository = walletsRepository, - sendFeatureToggles = sendFeatureToggles, generalUserWalletsListManager = generalUserWalletsListManager, wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, @@ -382,8 +365,4 @@ abstract class TangemApplication : Application(), ImageLoaderFactory { ) store.dispatch(GlobalAction.SetFeedbackManager(feedbackManager)) } - - private fun initWarningMessagesManager() { - store.dispatch(GlobalAction.SetWarningManager(WarningMessagesManager())) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/CurrencyConverter.kt b/app/src/main/java/com/tangem/tap/common/CurrencyConverter.kt deleted file mode 100644 index d30c440661..0000000000 --- a/app/src/main/java/com/tangem/tap/common/CurrencyConverter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.tap.common - -import com.tangem.common.extensions.isZero -import com.tangem.tap.common.extensions.scaleToFiat -import java.math.BigDecimal -import java.math.RoundingMode - -/** -[REDACTED_AUTHOR] - */ -class CurrencyConverter( - private val rateValue: BigDecimal, - private val decimals: Int, -) { - private val roundingMode = RoundingMode.HALF_UP - - fun toFiat(crypto: BigDecimal, fiatDecimals: Int = 2): BigDecimal { - return toFiatUnscaled(crypto).setScale(fiatDecimals, roundingMode) - } - - fun toFiatUnscaled(crypto: BigDecimal): BigDecimal { - return rateValue.multiply(crypto).setScale(decimals, roundingMode) - } - - fun toFiatWithPrecision(crypto: BigDecimal): BigDecimal { - return toFiatUnscaled(crypto).scaleToFiat(true) - } - - fun toCrypto(fiat: BigDecimal): BigDecimal { - if (fiat.isZero()) return fiat - - val fiatValue = fiat.setScale(rateValue.scale(), RoundingMode.UP) - val cryptoValue = fiatValue.divide(rateValue, RoundingMode.UP) - return cryptoValue.setScale(decimals, roundingMode) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/GlobalLayoutStateHandler.kt b/app/src/main/java/com/tangem/tap/common/GlobalLayoutStateHandler.kt deleted file mode 100644 index 0a51404cf9..0000000000 --- a/app/src/main/java/com/tangem/tap/common/GlobalLayoutStateHandler.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.tap.common - -import android.view.View -import android.view.ViewTreeObserver -import timber.log.Timber - -/** -[REDACTED_AUTHOR] - */ -class GlobalLayoutStateHandler( - private val view: T, - attachImmediately: Boolean = true, -) : ViewTreeObserver.OnGlobalLayoutListener { - - var onStateChanged: ((T) -> Unit)? = null - - private var isAttached: Boolean = false - - init { - if (attachImmediately) attach() - } - - private fun attach() { - if (isAttached) { - Timber.d("Already attached") - return - } - - isAttached = true - view.viewTreeObserver.addOnGlobalLayoutListener(this) - } - - fun detach() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - isAttached = false - } - - override fun onGlobalLayout() { - onStateChanged?.invoke(view) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/KeyboardObserver.kt b/app/src/main/java/com/tangem/tap/common/KeyboardObserver.kt deleted file mode 100644 index 47f579ca1b..0000000000 --- a/app/src/main/java/com/tangem/tap/common/KeyboardObserver.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tangem.tap.common - -import android.app.Activity -import android.graphics.Rect -import android.util.DisplayMetrics -import android.view.ViewTreeObserver.OnGlobalLayoutListener -import kotlin.math.absoluteValue - -class KeyboardObserver(activity: Activity) { - - private val decorView = activity.window.decorView - private val windowManager = activity.windowManager - private val originalWindowHeight: Int = getWindowHeight() - private val onGlobalLayoutListener: OnGlobalLayoutListener = OnGlobalLayoutListener { onGlobalLayout() } - - private var onKeyboardListener: ((Boolean) -> Unit)? = null - private var lastIsShow = false - private var lastWindowHeight = getWindowHeight() - - fun registerListener(listener: (Boolean) -> Unit) { - decorView.viewTreeObserver.addOnGlobalLayoutListener(onGlobalLayoutListener) - onKeyboardListener = listener - } - - fun unregisterListener() { - decorView.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalLayoutListener) - onKeyboardListener = null - } - - private fun getWindowHeight() = Rect().apply { decorView.getWindowVisibleDisplayFrame(this) }.bottom - - private fun onGlobalLayout() { - val currentWindowHeight = getWindowHeight() - if (isSoftKeyChanged()) { - lastWindowHeight = currentWindowHeight - return - } - - lastWindowHeight = currentWindowHeight - val isShow = originalWindowHeight != currentWindowHeight - if (lastIsShow == isShow) return - - lastIsShow = isShow - onKeyboardListener?.invoke(isShow) - } - - private fun isSoftKeyChanged() = (lastWindowHeight - getWindowHeight()).absoluteValue == getSoftKeyButtonHeight() - - private fun getSoftKeyButtonHeight(): Int { - val applicationDisplayHeight = DisplayMetrics().apply { - windowManager.defaultDisplay.getMetrics(this) - }.heightPixels - - val realDisplayHeight = DisplayMetrics().apply { - windowManager.defaultDisplay.getRealMetrics(this) - }.heightPixels - - return realDisplayHeight - applicationDisplayHeight - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt index 3b5ae220eb..56c286492d 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt @@ -22,44 +22,6 @@ sealed class Token( class ButtonShareAddress : Receive("Button - Share Address") } - sealed class Send( - event: String, - params: Map = mapOf(), - error: Throwable? = null, - ) : Token("Token / Send", event, params, error) { - - class ScreenOpened : Send(event = "Send Screen Opened") - class ButtonPaste : Send(event = "Button - Paste") - class ButtonQRCode : Send(event = "Button - QR Code") - class ButtonSwapCurrency : Send(event = "Button - Swap Currency") - - class AddressEntered(sourceType: SourceType, validationResult: ValidationResult) : Send( - event = "Address Entered", - params = mapOf( - "Source" to sourceType.name, - "Validation" to validationResult.name, - ), - ) { - enum class SourceType { - QRCode, PasteButton, PastePopup - } - - enum class ValidationResult { - Success, Fail - } - } - - class SelectedCurrency(currency: CurrencyType) : Send( - event = "Selected Currency", - params = mapOf("Type" to currency.value), - ) { - - enum class CurrencyType(val value: String) { - Token(value = "Token"), AppCurrency(value = "App Currency") - } - } - } - sealed class Topup( event: String, params: Map = mapOf(), diff --git a/app/src/main/java/com/tangem/tap/common/entities/Button.kt b/app/src/main/java/com/tangem/tap/common/entities/Button.kt deleted file mode 100644 index b0aca7f994..0000000000 --- a/app/src/main/java/com/tangem/tap/common/entities/Button.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.tap.common.entities - -import com.tangem.tap.features.send.redux.states.ButtonState - -open class Button(val enabled: Boolean) - -open class IndeterminateProgressButton( - val state: ButtonState, -) : Button(state != ButtonState.DISABLED) { - - val progressState: ProgressState - get() = when (state) { - ButtonState.PROGRESS -> ProgressState.Loading - else -> ProgressState.Done - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Bitmap.kt b/app/src/main/java/com/tangem/tap/common/extensions/Bitmap.kt deleted file mode 100644 index 76eea0c892..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/Bitmap.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.common.extensions - -import android.graphics.Bitmap -import java.io.ByteArrayOutputStream - -@Suppress("MagicNumber") -fun Bitmap.toByteArray(): ByteArray { - val stream = ByteArrayOutputStream() - this.compress(Bitmap.CompressFormat.JPEG, 20, stream) - return stream.toByteArray() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt index 5d7a14709f..243a2f8770 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt @@ -12,12 +12,12 @@ fun FragmentManager.showFragmentAllowingStateLoss(name: String, fragmentProvider val currentFragmentName = getBackStackEntryAt(backStackEntryCount - 1).name if (name == currentFragmentName) { - Timber.d("Fragment $name is already at the top of the stack") + Timber.i("Fragment $name is already at the top of the stack") return } } - Timber.d("Showing $name route") + Timber.i("Showing $name route") val isPoppedBack = popBackStackImmediate(name, 0) @@ -30,9 +30,9 @@ fun FragmentManager.showFragmentAllowingStateLoss(name: String, fragmentProvider fragment.showFragment(fragmentManager = this, name) } - Timber.d("Route $name is shown") + Timber.i("Route $name is shown") } else { - Timber.d("Route $name is found in backstack and shown") + Timber.i("Route $name is found in backstack and shown") } } diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt b/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt index a2206cb3bc..773bca1bd5 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Specific.kt @@ -1,6 +1,5 @@ package com.tangem.tap.common.extensions -import com.tangem.common.extensions.isZero import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -25,20 +24,4 @@ fun BigDecimal.toFormattedString( fun BigDecimal.stripZeroPlainString(): String = this.stripTrailingZeros().toPlainString() -// 0.00 -> 0.00 -// 0.00002345 -> 0.00002 -// 1.00002345 -> 1.00 -// 1.45002345 -> 1.45 -fun BigDecimal.scaleToFiat(applyPrecision: Boolean = false): BigDecimal { - if (this.isZero()) return this - - val scaledFiat = this.setScale(2, RoundingMode.DOWN) - return if (scaledFiat.isZero() && applyPrecision) this.setPrecision(1) else scaledFiat -} - -fun BigDecimal.setPrecision(precision: Int, roundingMode: RoundingMode = RoundingMode.DOWN): BigDecimal { - if (precision == precision() || scale() <= precision) return this - return this.setScale(scale() - precision() + precision, roundingMode) -} - fun BigDecimal.isPositive(): Boolean = this.signum() == 1 \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/WebView.kt b/app/src/main/java/com/tangem/tap/common/extensions/WebView.kt deleted file mode 100644 index 1afe53b3ba..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/WebView.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.tap.common.extensions - -import android.webkit.WebView - -fun WebView.stop() { - stopLoading() - pauseTimers() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt b/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt index e3add3c7cc..e9432205b9 100644 --- a/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt +++ b/app/src/main/java/com/tangem/tap/common/feedback/FeedbackDataBuilder.kt @@ -31,12 +31,6 @@ class FeedbackDataBuilder( builder.appendDelimiter() builder.appendKeyValue("Blockchain", walletInfo.blockchain.fullName) builder.appendKeyValue("Derivation path", walletInfo.derivationPath) - - // enable later - // if (walletInfo.blockchain == Blockchain.Bitcoin) { - // builder.appendKeyValue("XPUB", infoHolder.extendedPublicKey) - // } - builder.appendKeyValue("Outputs count", walletInfo.outputsCount) if (walletInfo.tokens.isNotEmpty()) { diff --git a/app/src/main/java/com/tangem/tap/common/haptic/DefaultHapticManager.kt b/app/src/main/java/com/tangem/tap/common/haptic/DefaultHapticManager.kt deleted file mode 100644 index 688dea7f67..0000000000 --- a/app/src/main/java/com/tangem/tap/common/haptic/DefaultHapticManager.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.tap.common.haptic - -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import com.tangem.core.ui.haptic.HapticManager - -class DefaultHapticManager(private val vibrator: Vibrator) : HapticManager { - - override fun vibrateShort() { - vibrate(VIBRATION_SHORT_DURATION) - } - - override fun vibrateMeduim() { - vibrate(VIBRATION_MEDIUM_LOW_DURATION) - } - - override fun vibrateLong() { - vibrate(VIBRATION_MEDIUM_DURATION) - } - - private fun vibrate(durationMs: Long) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) - } else { - vibrator.vibrate(durationMs) - } - } - - companion object { - const val VIBRATION_SHORT_DURATION = 50L - const val VIBRATION_MEDIUM_LOW_DURATION = 75L - const val VIBRATION_MEDIUM_DURATION = 100L - const val VIBRATION_LONG_DURATION = 200L - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/haptic/DefaultVibratorHapticManager.kt b/app/src/main/java/com/tangem/tap/common/haptic/DefaultVibratorHapticManager.kt new file mode 100644 index 0000000000..2bcae01131 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/haptic/DefaultVibratorHapticManager.kt @@ -0,0 +1,38 @@ +package com.tangem.tap.common.haptic + +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.annotation.ChecksSdkIntAtLeast +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager + +internal class DefaultVibratorHapticManager( + private val vibrator: Vibrator, +) : VibratorHapticManager { + + private val isHapticEnabled = deviceSupportsVibrationEffects() + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) + private fun deviceSupportsVibrationEffects(): Boolean = when { + !vibrator.hasVibrator() -> false + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + vibrator.areAllEffectsSupported( + TangemHapticEffect.OneTime.DoubleClick.code, + TangemHapticEffect.OneTime.HeavyClick.code, + TangemHapticEffect.OneTime.Tick.code, + TangemHapticEffect.OneTime.Click.code, + ) == Vibrator.VIBRATION_EFFECT_SUPPORT_YES + + Build.VERSION.SDK_INT == Build.VERSION_CODES.Q -> true + + else -> false + } + + override fun performOneTime(effect: TangemHapticEffect.OneTime) { + if (!isHapticEnabled) return + + vibrator.vibrate(VibrationEffect.createPredefined(effect.code)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/leapfrogWidget/TestLeapfrogFragment.kt b/app/src/main/java/com/tangem/tap/common/leapfrogWidget/TestLeapfrogFragment.kt deleted file mode 100644 index 8253e64b37..0000000000 --- a/app/src/main/java/com/tangem/tap/common/leapfrogWidget/TestLeapfrogFragment.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.tangem.tap.common.leapfrogWidget - -import android.os.Bundle -import android.view.View -import android.widget.FrameLayout -import androidx.fragment.app.Fragment -import androidx.transition.TransitionInflater -import by.kirich1409.viewbindingdelegate.viewBinding -import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidget -import com.tangem.sdk.ui.widget.leapfrogWidget.LeapfrogWidgetState -import com.tangem.tap.domain.twins.TwinsCardWidget -import com.tangem.wallet.R -import com.tangem.wallet.databinding.TestLeapfrogFragmentBinding - -class TestLeapfrogFragment : Fragment(R.layout.test_leapfrog_fragment) { - - private lateinit var twinsCardWidget: TwinsCardWidget - private val binding: TestLeapfrogFragmentBinding by viewBinding(TestLeapfrogFragmentBinding::bind) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val inflater = TransitionInflater.from(requireContext()) - exitTransition = inflater.inflateTransition(R.transition.fade) - } - - @Suppress("MagicNumber") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val leapfrogContainer: FrameLayout = view.findViewById(R.id.leapfrog_views_container) - val leapfrog = LeapfrogWidget(leapfrogContainer) - twinsCardWidget = TwinsCardWidget(leapfrog) { 200f } - - binding.btnTwinWelcome.setOnClickListener { - twinsCardWidget.toWelcome() - } - binding.btnTwinToLeapfrog.setOnClickListener { - twinsCardWidget.toLeapfrog() - } - binding.btnTwinActivate.setOnClickListener { - twinsCardWidget.toActivate() - } - - binding.btnLpInit.setOnClickListener { - leapfrog.initViews() - } - binding.btnLpUnfold.setOnClickListener { - leapfrog.unfold() - } - binding.btnLpFold.setOnClickListener { - leapfrog.fold() - } - binding.btnLpLeap.setOnClickListener { - leapfrog.leap() - } - binding.btnLpLeapBack.setOnClickListener { - leapfrog.leapBack() - } - } - - override fun onStart() { - super.onStart() - leapfrogWidgetState?.let { twinsCardWidget.leapfrogWidget.applyState(it) } - } - - override fun onStop() { - super.onStop() - leapfrogWidgetState = twinsCardWidget.leapfrogWidget.getState() - } -} - -private var leapfrogWidgetState: LeapfrogWidgetState? = null \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt new file mode 100644 index 0000000000..7a7e2c72b0 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt @@ -0,0 +1,48 @@ +package com.tangem.tap.common.log + +import android.util.Log +import com.orhanobut.logger.AndroidLogAdapter +import com.orhanobut.logger.Logger +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.wallet.BuildConfig +import timber.log.Timber + +/** + * Tangem app logger + * + * @property settingsRepository repository for saving logs + * +[REDACTED_AUTHOR] + */ +class TangemAppLoggerInitializer( + private val settingsRepository: SettingsRepository, +) { + + /** Initialize */ + fun initialize() { + if (IS_LOG_ENABLED) { + Logger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) + } + + Timber.plant(tree = createTimberTree()) + } + + private fun createTimberTree(): Timber.Tree { + return object : Timber.DebugTree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (IS_LOG_ENABLED) { + Logger.log(priority, tag, message, t) + } + + if (PERMITTED_PRIORITY.contains(priority)) { + settingsRepository.saveLogMessage(message) + } + } + } + } + + private companion object { + val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED + val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt index 0da020d6bf..fad6b562f7 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt @@ -4,11 +4,6 @@ import com.tangem.Log import com.tangem.LogFormat import com.tangem.TangemSdkLogger import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock /** * CardSDK logger implementation @@ -16,7 +11,6 @@ import kotlinx.coroutines.sync.withLock * @property levels logging levels * @property messageFormatter message formatter * @property settingsRepository settings repository - * @property dispatchers coroutine dispatchers provider * [REDACTED_AUTHOR] */ @@ -24,19 +18,11 @@ internal class TangemCardSDKLogger( private val levels: List, private val messageFormatter: LogFormat, private val settingsRepository: SettingsRepository, - private val dispatchers: CoroutineDispatcherProvider, ) : TangemSdkLogger { - private val scope = CoroutineScope(dispatchers.main) - private val mutex = Mutex() - override fun log(message: () -> String, level: Log.Level) { if (!levels.contains(level)) return - scope.launch(dispatchers.main) { - mutex.withLock { - settingsRepository.updateAppLogs(message = messageFormatter.format(message, level)) - } - } + settingsRepository.saveLogMessage(message = messageFormatter.format(message, level)) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/recyclerView/SpaceItemDecoration.kt b/app/src/main/java/com/tangem/tap/common/recyclerView/SpaceItemDecoration.kt deleted file mode 100644 index 4ea08ea19e..0000000000 --- a/app/src/main/java/com/tangem/tap/common/recyclerView/SpaceItemDecoration.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.tangem.tap.common.recyclerView - -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.tangem.sdk.extensions.dpToPx - -class SpaceItemDecoration( - private val horizontalSpaceDp: Float, - private val verticalSpaceDp: Float, -) : RecyclerView.ItemDecoration() { - - private lateinit var space: Space - - override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - if (state.itemCount == 0) return - if (!::space.isInitialized) { - space = Space( - view.dpToPx(horizontalSpaceDp).toInt(), - view.dpToPx(verticalSpaceDp).toInt(), - ) - } - - outRect.left = space.horizontal - outRect.right = space.horizontal - - when (state.itemCount) { - 1 -> { - outRect.top = space.vertical - outRect.bottom = space.vertical - } - else -> { - val adapterPosition = parent.getChildAdapterPosition(view) - if (adapterPosition == -1) return - - when (adapterPosition) { - 0 -> { - // first - outRect.top = space.vertical - outRect.bottom = space.vertical / 2 - } - state.itemCount - 1 -> { - // last - outRect.top = space.vertical / 2 - outRect.bottom = space.vertical - } - else -> { - // middle - outRect.top = space.vertical / 2 - outRect.bottom = space.vertical / 2 - } - } - } - } - } - - private data class Space( - val horizontal: Int, - val vertical: Int, - ) - - companion object { - fun all(dp: Float): SpaceItemDecoration = SpaceItemDecoration(dp, dp) - fun vertical(dp: Float): SpaceItemDecoration = SpaceItemDecoration(0f, dp) - fun horizontal(dp: Float): SpaceItemDecoration = SpaceItemDecoration(dp, 0f) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index d047620b5e..cdc2bed475 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -10,7 +10,6 @@ import com.tangem.tap.features.onboarding.products.otherCards.redux.OnboardingOt import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsReducer import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletReducer import com.tangem.tap.features.saveWallet.redux.SaveWalletReducer -import com.tangem.tap.features.send.redux.reducers.SendScreenReducer import com.tangem.tap.features.tokens.legacy.redux.TokensReducer import com.tangem.tap.features.welcome.redux.WelcomeReducer import com.tangem.tap.proxy.AppStateHolder @@ -28,7 +27,6 @@ fun appReducer(action: Action, state: AppState?, appStateHolder: AppStateHolder) onboardingWalletState = OnboardingWalletReducer.reduce(action, state), onboardingOtherCardsState = OnboardingOtherCardsReducer.reduce(action, state), twinCardsState = TwinCardsReducer.reduce(action, state), - sendState = SendScreenReducer.reduce(action, state.sendState), detailsState = DetailsReducer.reduce(action, state), disclaimerState = DisclaimerReducer.reduce(action, state), tokensState = TokensReducer.reduce(action, state), diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index 2a83cbf4df..bdcb0bd1c1 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -25,8 +25,6 @@ import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWallet import com.tangem.tap.features.onboarding.products.wallet.redux.OnboardingWalletState import com.tangem.tap.features.saveWallet.redux.SaveWalletMiddleware import com.tangem.tap.features.saveWallet.redux.SaveWalletState -import com.tangem.tap.features.send.redux.middlewares.SendMiddleware -import com.tangem.tap.features.send.redux.states.SendState import com.tangem.tap.features.tokens.legacy.redux.TokensState import com.tangem.tap.features.wallet.redux.middlewares.TradeCryptoMiddleware import com.tangem.tap.features.welcome.redux.WelcomeMiddleware @@ -44,7 +42,6 @@ data class AppState( val onboardingWalletState: OnboardingWalletState = OnboardingWalletState(), val onboardingOtherCardsState: OnboardingOtherCardsState = OnboardingOtherCardsState(), val twinCardsState: TwinCardsState = TwinCardsState(), - val sendState: SendState = SendState(), val detailsState: DetailsState = DetailsState(), val disclaimerState: DisclaimerState = DisclaimerState(), val tokensState: TokensState = TokensState(), @@ -77,7 +74,6 @@ data class AppState( OnboardingWalletMiddleware.handler, OnboardingOtherCardsMiddleware.handler, TwinCardsMiddleware.handler, - SendMiddleware().sendMiddleware, DetailsMiddleware().detailsMiddleware, DisclaimerMiddleware().disclaimerMiddleware, WalletConnectMiddleware().walletConnectMiddleware, diff --git a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt index 30b127fda1..21cca072f7 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt @@ -1,19 +1,15 @@ package com.tangem.tap.common.redux -import com.tangem.domain.common.LogConfig import org.rekotlin.Middleware import timber.log.Timber /** [REDACTED_AUTHOR] */ -val logMiddleware: Middleware = { dispatch, appState -> +val logMiddleware: Middleware = { _, _ -> { nextDispatch -> { action -> - if (LogConfig.storeAction) { - Timber.d("Dispatch action: $action") - // printOnboardingWalletState() - } + Timber.i("Dispatch action: $action") nextDispatch(action) } } diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt index ade5d123b2..2b9e953b46 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt @@ -4,7 +4,6 @@ import com.tangem.common.CompletionResult import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.datasource.config.ConfigManager import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.feedback.FeedbackData @@ -13,8 +12,6 @@ import com.tangem.tap.common.redux.DebugErrorAction import com.tangem.tap.common.redux.ErrorAction import com.tangem.tap.common.redux.NotificationAction import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.configurable.warningMessage.WarningMessage -import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager import org.rekotlin.Action sealed class GlobalAction : Action { @@ -72,10 +69,7 @@ sealed class GlobalAction : Action { val walletPublicKey: ByteArray, ) : GlobalAction() - data class HideWarningMessage(val warning: WarningMessage) : GlobalAction() - data class SetConfigManager(val configManager: ConfigManager) : GlobalAction() - data class SetWarningManager(val warningManager: WarningMessagesManager) : GlobalAction() data class SetFeedbackManager(val feedbackManager: LegacyFeedbackManager) : GlobalAction() data class SendEmail(val feedbackData: FeedbackData, val scanResponse: ScanResponse?) : GlobalAction() @@ -93,6 +87,4 @@ sealed class GlobalAction : Action { object FetchUserCountry : GlobalAction() { data class Success(val countryCode: String) : GlobalAction() } - - data class ChangeAppThemeMode(val appThemeMode: AppThemeMode) : GlobalAction() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt index 9c6882deda..43d85d7fc5 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt @@ -12,7 +12,6 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.send.redux.SendAction import com.tangem.tap.network.exchangeServices.BuyExchangeService import com.tangem.tap.network.exchangeServices.CardExchangeRules import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager @@ -56,18 +55,6 @@ private fun handleAction(action: Action, appState: () -> AppState?) { is GlobalAction.RestoreAppCurrency -> { restoreAppCurrency() } - is GlobalAction.HideWarningMessage -> { - store.state.globalState.warningManager?.let { - if (it.hideWarning(action.warning)) { - // if (WarningMessagesManager.isAlreadySignedHashesWarning()) { - // // TODO: No appropriate warningMessage identification. Make it better later - // store.dispatch(WalletAction.Warnings.CheckHashesCount.SaveCardId) - // } - - store.dispatch(SendAction.Warnings.Update) - } - } - } is GlobalAction.SendEmail -> { store.state.globalState.feedbackManager?.sendEmail( feedbackData = action.feedbackData, diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt index d85d35f457..6b011082d2 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt @@ -53,7 +53,6 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde is GlobalAction.SetConfigManager -> { globalState.copy(configManager = action.configManager) } - is GlobalAction.SetWarningManager -> globalState.copy(warningManager = action.warningManager) is GlobalAction.UpdateWalletSignedHashes -> { val card = globalState.scanResponse?.card ?: return globalState val wallet = card.wallets @@ -92,9 +91,6 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde userCountryCode = action.countryCode, ) } - is GlobalAction.ChangeAppThemeMode -> globalState.copy( - appThemeMode = action.appThemeMode, - ) else -> globalState } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt index 50f8f85f47..420951087b 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt @@ -7,7 +7,6 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.StateDialog import com.tangem.tap.common.feedback.LegacyFeedbackManager import com.tangem.tap.domain.TapWalletManager -import com.tangem.tap.domain.configurable.warningMessage.WarningMessagesManager import com.tangem.tap.features.onboarding.OnboardingManager import com.tangem.tap.network.exchangeServices.CurrencyExchangeManager import org.rekotlin.StateType @@ -19,7 +18,6 @@ data class GlobalState( val cardVerifiedOnline: Boolean = false, val tapWalletManager: TapWalletManager = TapWalletManager(), val configManager: ConfigManager? = null, - val warningManager: WarningMessagesManager? = null, val feedbackManager: LegacyFeedbackManager? = null, val appCurrency: AppCurrency = AppCurrency.Default, val scanCardFailsCounter: Int = 0, diff --git a/app/src/main/java/com/tangem/tap/common/snackBar/MaxAmountSnackbar.kt b/app/src/main/java/com/tangem/tap/common/snackBar/MaxAmountSnackbar.kt deleted file mode 100644 index 939fc17c61..0000000000 --- a/app/src/main/java/com/tangem/tap/common/snackBar/MaxAmountSnackbar.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.tangem.tap.common.snackBar - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams -import com.google.android.material.snackbar.BaseTransientBottomBar -import com.google.android.material.snackbar.ContentViewCallback -import com.google.android.material.snackbar.Snackbar -import com.tangem.sdk.extensions.dpToPx -import com.tangem.wallet.R - -/** -[REDACTED_AUTHOR] - */ -class MaxAmountSnackbar( - parent: ViewGroup, - content: MaxAmountSnackbarView, -) : BaseTransientBottomBar(parent, content, content) { - - companion object { - - fun make(view: View, onClick: () -> Unit): MaxAmountSnackbar { - val parent = view.findSuitableParent() ?: throw IllegalArgumentException( - "No suitable parent found from the given view. Please provide a valid view.", - ) - val inflater = LayoutInflater.from(parent.context) - val customView = inflater.inflate(R.layout.view_snackbar_max_amount, parent, false) as MaxAmountSnackbarView - customView.setOnClickListener { onClick() } - - return MaxAmountSnackbar(parent, customView).apply { - updateBottomMargin() - duration = Snackbar.LENGTH_INDEFINITE - } - } - - private fun MaxAmountSnackbar.updateBottomMargin() { - ViewCompat.setOnApplyWindowInsetsListener(this.view) { _, insets -> - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom - val bottomInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom - - this.view.updateLayoutParams { - bottomMargin = imeInsets - bottomInsets + context.dpToPx(dp = 8f).toInt() - } - - insets - } - } - - private fun View?.findSuitableParent(): ViewGroup? { - var view = this - var fallback: ViewGroup? = null - do { - if (view is CoordinatorLayout) { - return view - } else if (view is FrameLayout) { - if (view.id == android.R.id.content) { - return view - } else { - fallback = view - } - } - - if (view != null) { - val parent = view.parent - view = if (parent is View) parent else null - } - } while (view != null) - return fallback - } - } -} - -class MaxAmountSnackbarView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : ConstraintLayout(context, attrs, defStyleAttr), ContentViewCallback { - - init { - View.inflate(context, R.layout.view_snackbar_max_amount_content, this) - clipToPadding = false - } - - override fun animateContentIn(delay: Int, duration: Int) { - } - - override fun animateContentOut(delay: Int, duration: Int) { - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/text/DecimalDigitsInputFilter.kt b/app/src/main/java/com/tangem/tap/common/text/DecimalDigitsInputFilter.kt deleted file mode 100644 index 2201e794cc..0000000000 --- a/app/src/main/java/com/tangem/tap/common/text/DecimalDigitsInputFilter.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tangem.tap.common.text - -import android.text.InputFilter -import android.text.Spanned -import java.util.regex.Pattern - -/** -[REDACTED_AUTHOR] - */ -class DecimalDigitsInputFilter( - digitsBeforeDecimal: Int, - digitsAfterDecimal: Int, - private val decimalSeparator: String, -) : InputFilter { - private val pattern: Pattern = Pattern.compile( - "(([1-9]{1}[0-9]{0,${digitsBeforeDecimal - 1}})?||[0]{1})" + - "((\\$decimalSeparator[0-9]{0,$digitsAfterDecimal})?)||(\\$decimalSeparator)?", - ) - - override fun filter( - source: CharSequence, - sourceStart: Int, - sourceEnd: Int, - destination: Spanned, - destinationStart: Int, - destinationEnd: Int, - ): CharSequence? { - val destString = destination.toString() - val prefix = destString.substring(0, destinationStart) - val suffix = destString.substring(destinationEnd, destString.length) - val newDestination = prefix + suffix - - val resultPrefix = newDestination.substring(0, destinationStart) - val resultSuffix = newDestination.substring(destinationStart, newDestination.length) - val result = resultPrefix + source.toString() + resultSuffix - - return if (pattern.matcher(result).matches()) { - null - } else { - val replacedWithAppropriateDecimalSeparator = setDecimalSeparator(result, decimalSeparator) - if (pattern.matcher(replacedWithAppropriateDecimalSeparator).matches()) { - decimalSeparator - } else { - "" - } - } - } - - companion object { - fun setDecimalSeparator(value: String, decimalSeparator: String): String { - if (value.contains(decimalSeparator)) return value - - return if (decimalSeparator == ".") { - value.replace(",", decimalSeparator) - } else { - value.replace(".", decimalSeparator) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/text/Truncate.kt b/app/src/main/java/com/tangem/tap/common/text/Truncate.kt deleted file mode 100644 index 9131ec1023..0000000000 --- a/app/src/main/java/com/tangem/tap/common/text/Truncate.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.tangem.tap.common.text - -import android.widget.TextView -import com.tangem.tap.common.extensions.isEven - -/** -[REDACTED_AUTHOR] - */ -enum class TruncateType { - START, MIDDLE, END -} - -interface Truncate { - fun apply(tv: TextView, text: String, with: String): String - - companion object { - fun create(type: TruncateType): Truncate { - return when (type) { - TruncateType.START -> TruncateStart() - TruncateType.MIDDLE -> TruncateMiddle() - TruncateType.END -> TruncateEnd() - } - } - } -} - -abstract class BaseTruncate : Truncate { - protected var hasBeenTruncated = false - - override fun apply(tv: TextView, text: String, with: String): String { - val roughLength = getRoughFitLength(tv, text) - val fittedText = preciseFitting(tv, roughTruncate(text, roughLength), with) - return if (hasBeenTruncated) attachWith(fittedText, with) else fittedText - } - - private fun getRoughFitLength(tv: TextView, text: String): Int { - val existingSpace = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd) - val textWillTakeSpace = tv.paint.measureText(text) - val overSizeRatio: Float = textWillTakeSpace / existingSpace - - val maxLengthOfText = text.length / overSizeRatio - if (text.length <= maxLengthOfText) return text.length - - return maxLengthOfText.toInt() - } - - private fun preciseFitting(tv: TextView, text: String, with: String): String { - if (!hasBeenTruncated) return text - - val spaceForText = tv.measuredWidth - (tv.paddingStart + tv.paddingEnd) - - var fittedText = text - while (tv.paint.measureText(fittedText + with) > spaceForText) { - fittedText = preciseTruncate(fittedText) - } - return fittedText - } - - protected abstract fun roughTruncate(text: String, residualLength: Int): String - protected abstract fun preciseTruncate(text: String): String - protected abstract fun attachWith(text: String, with: String): String -} - -class TruncateStart : BaseTruncate() { - override fun roughTruncate(text: String, residualLength: Int): String { - if (text.length <= residualLength) return text - - hasBeenTruncated = true - return text.substring(residualLength, text.length) - } - - override fun preciseTruncate(text: String): String = text.substring(1, text.length) - - override fun attachWith(text: String, with: String): String = with + text -} - -class TruncateMiddle : BaseTruncate() { - - override fun roughTruncate(text: String, residualLength: Int): String { - if (text.length <= residualLength || residualLength < 0) return text - - hasBeenTruncated = true - val halfOfResidualLength = residualLength / 2 - val leftSide = text.substring(0, halfOfResidualLength) - val rightSide = text.substring(text.length - halfOfResidualLength, text.length) - - return leftSide + rightSide - } - - override fun preciseTruncate(text: String): String { - val middlePosition = text.length / 2 - return if (text.length.isEven()) { - val leftSide = text.substring(0, middlePosition - 1) - val rightSide = text.substring(middlePosition, text.length) - leftSide + rightSide - } else { - val leftSide = text.substring(0, middlePosition) - val rightSide = text.substring(middlePosition + 1, text.length) - leftSide + rightSide - } - } - - override fun attachWith(text: String, with: String): String { - val cuttingPosition = text.length / 2 - val leftSide = text.substring(0, cuttingPosition) - val rightSide = text.substring(cuttingPosition, text.length) - return leftSide + with + rightSide - } -} - -class TruncateEnd : BaseTruncate() { - override fun roughTruncate(text: String, residualLength: Int): String { - if (text.length <= residualLength) return text - - hasBeenTruncated = true - return text.substring(0, residualLength) - } - - override fun preciseTruncate(text: String): String = text.substring(0, text.length - 1) - - override fun attachWith(text: String, with: String): String = text + with -} - -fun TextView.truncateWith(text: String, type: TruncateType, with: String = "..."): String { - val truncate = Truncate.create(type) - return truncate.apply(this, text, with) -} - -fun TextView.truncateMiddleWith(text: String, with: String = "..."): String = - this.truncateWith(text, TruncateType.MIDDLE, with) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt b/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt deleted file mode 100644 index 22817c3f93..0000000000 --- a/app/src/main/java/com/tangem/tap/common/toggleWidget/IndeterminateProgressButtonWidget.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.tangem.tap.common.toggleWidget - -import android.graphics.drawable.Drawable -import android.view.View -import com.google.android.material.button.MaterialButton -import com.tangem.tap.common.entities.ProgressState -import com.tangem.tap.common.extensions.hide -import com.tangem.tap.common.extensions.show - -/** -[REDACTED_AUTHOR] - */ -open class IndeterminateProgressButtonWidget( - private val button: MaterialButton, - private val progress: View, - initialState: ProgressState = ProgressState.Done, -) : ViewStateWidget { - - private var text: CharSequence = button.text - private var icon: Drawable? = button.icon - private var iconGravity: Int? = button.iconGravity - - init { - if (initialState != ProgressState.Done) changeState(initialState) - } - - var isEnabled: Boolean - get() = button.isEnabled - set(value) { - button.isEnabled = value - } - - override val mainView: View = button - - override fun changeState(state: WidgetState) { - val progressState = state as? ProgressState ?: return - - when (progressState) { - ProgressState.Done, ProgressState.Error -> switchToNone() - ProgressState.Loading -> switchToProgress() - else -> {} - } - } - - protected open fun switchToNone() { - button.isClickable = true - button.text = text - button.icon = icon - iconGravity?.let { button.iconGravity = it } - - progress.hide() - } - - protected open fun switchToProgress() { - button.isClickable = false - button.text = "" - button.icon = null - - progress.show() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt b/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt index 903d5542ea..c275de1626 100644 --- a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt @@ -2,33 +2,19 @@ package com.tangem.tap.data import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock /** * BlockchainSDK logger implementation * * @property settingsRepository settings repository - * @property dispatchers coroutine dispatchers provider * [REDACTED_AUTHOR] */ internal class TangemBlockchainSDKLogger( private val settingsRepository: SettingsRepository, - private val dispatchers: CoroutineDispatcherProvider, ) : BlockchainSDKLogger { - private val scope = CoroutineScope(dispatchers.main) - private val mutex = Mutex() - override fun log(level: BlockchainSDKLogger.Level, message: String) { - scope.launch(dispatchers.main) { - mutex.withLock { - settingsRepository.updateAppLogs(message) - } - } + settingsRepository.saveLogMessage(message) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/HapticModule.kt b/app/src/main/java/com/tangem/tap/di/HapticModule.kt index bf8439f09e..dc04a3ce72 100644 --- a/app/src/main/java/com/tangem/tap/di/HapticModule.kt +++ b/app/src/main/java/com/tangem/tap/di/HapticModule.kt @@ -4,9 +4,9 @@ import android.content.Context import android.os.Build import android.os.Vibrator import android.os.VibratorManager -import com.tangem.tap.common.haptic.DefaultHapticManager -import com.tangem.core.ui.haptic.HapticManager -import com.tangem.core.ui.haptic.MockHapticManager +import com.tangem.tap.common.haptic.DefaultVibratorHapticManager +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,7 +20,7 @@ class HapticModule { @Provides @Singleton - fun provideHapticManager(@ApplicationContext context: Context): HapticManager { + fun provideHapticManager(@ApplicationContext context: Context): VibratorHapticManager { val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager vibratorManager.defaultVibrator @@ -29,9 +29,12 @@ class HapticModule { } return if (vibrator.hasVibrator()) { - DefaultHapticManager(vibrator = vibrator) + DefaultVibratorHapticManager(vibrator = vibrator) } else { - MockHapticManager + // mock + object : VibratorHapticManager { + override fun performOneTime(effect: TangemHapticEffect.OneTime) = Unit + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt index 22a522e553..44631d4c05 100644 --- a/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UiDependenciesModule.kt @@ -2,7 +2,7 @@ package com.tangem.tap.di import androidx.compose.material3.SnackbarHostState import com.tangem.core.ui.UiDependencies -import com.tangem.core.ui.haptic.HapticManager +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler import com.tangem.core.ui.theme.AppThemeModeHolder import dagger.Module @@ -17,9 +17,12 @@ internal object UiDependenciesModule { @Provides @Singleton - fun provideUiDependencies(hapticManager: HapticManager, appThemeModeHolder: AppThemeModeHolder): UiDependencies { + fun provideUiDependencies( + vibratorHapticManager: VibratorHapticManager, + appThemeModeHolder: AppThemeModeHolder, + ): UiDependencies { return object : UiDependencies { - override val hapticManager = hapticManager + override val vibratorHapticManager = vibratorHapticManager override val appThemeModeHolder = appThemeModeHolder override val globalSnackbarHostState: SnackbarHostState = SnackbarHostState() override val eventMessageHandler: EventMessageHandler = EventMessageHandler() diff --git a/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt b/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt deleted file mode 100644 index 90906e322d..0000000000 --- a/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.tap.di.data - -import com.tangem.blockchain.common.logging.BlockchainSDKLogger -import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.tap.data.TangemBlockchainSDKLogger -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object BlockchainSDKLoggerModule { - - @Provides - @Singleton - fun provideBlockchainSDKLogger( - settingsRepository: SettingsRepository, - dispatchers: CoroutineDispatcherProvider, - ): BlockchainSDKLogger { - return TangemBlockchainSDKLogger(settingsRepository, dispatchers) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt similarity index 59% rename from app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt rename to app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt index 9b326b64c7..e361ca70a6 100644 --- a/app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt @@ -3,9 +3,11 @@ package com.tangem.tap.di.data import com.tangem.Log import com.tangem.LogFormat import com.tangem.TangemSdkLogger +import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.common.log.TangemCardSDKLogger -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.tap.data.TangemBlockchainSDKLogger import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,14 +16,17 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object CardSDKLoggerModule { +internal object TangemLoggingModule { @Provides @Singleton - fun provideCardSDKLogger( - settingsRepository: SettingsRepository, - dispatchers: CoroutineDispatcherProvider, - ): TangemSdkLogger { + fun provideAppLoggerInitializer(settingsRepository: SettingsRepository): TangemAppLoggerInitializer { + return TangemAppLoggerInitializer(settingsRepository) + } + + @Provides + @Singleton + fun provideCardSDKLogger(settingsRepository: SettingsRepository): TangemSdkLogger { val logLevels = listOf( Log.Level.ApduCommand, Log.Level.Apdu, @@ -40,7 +45,12 @@ internal object CardSDKLoggerModule { levels = logLevels, messageFormatter = LogFormat.StairsFormatter(), settingsRepository = settingsRepository, - dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideBlockchainSDKLogger(settingsRepository: SettingsRepository): BlockchainSDKLogger { + return TangemBlockchainSDKLogger(settingsRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index 0f3fab437c..c74a09521c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -6,6 +6,7 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.demo.DemoConfig import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.tap.domain.card.DefaultDeleteSavedAccessCodesUseCase @@ -69,8 +70,9 @@ internal object CardDomainModule { @Singleton fun provideGetExtendedPublicKeyForCurrencyUseCase( derivationsRepository: DerivationsRepository, + walletManagersFacade: WalletManagersFacade, ): GetExtendedPublicKeyForCurrencyUseCase { - return GetExtendedPublicKeyForCurrencyUseCase(derivationsRepository) + return GetExtendedPublicKeyForCurrencyUseCase(derivationsRepository, walletManagersFacade) } @Provides diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 765e3bcb7f..28b30cb9e3 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -1,6 +1,9 @@ package com.tangem.tap.di.domain import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.GetTokenMarketInfoUseCase +import com.tangem.domain.markets.GetTokenPriceChartUseCase +import com.tangem.domain.markets.GetTokenQuotesUseCase import com.tangem.domain.markets.repositories.MarketsTokenRepository import dagger.Module import dagger.Provides @@ -19,4 +22,22 @@ object MarketsDomainModule { ): GetMarketsTokenListFlowUseCase { return GetMarketsTokenListFlowUseCase(marketsTokenRepository = marketsTokenRepository) } + + @Provides + @Singleton + fun provideGetTokenPriceChartUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenPriceChartUseCase { + return GetTokenPriceChartUseCase(marketsTokenRepository = marketsTokenRepository) + } + + @Provides + @Singleton + fun provideGetTokenMarketInfoUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenMarketInfoUseCase { + return GetTokenMarketInfoUseCase(marketsTokenRepository = marketsTokenRepository) + } + + @Provides + @Singleton + fun provideGetTokenQuotesUseCase(marketsTokenRepository: MarketsTokenRepository): GetTokenQuotesUseCase { + return GetTokenQuotesUseCase(marketsTokenRepository = marketsTokenRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index c05129c777..bebc27226f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -197,5 +197,11 @@ internal object SettingsDomainModule { ): NeverRequestPermissionUseCase { return NeverRequestPermissionUseCase(repository = permissionRepository) } + + @Provides + @Singleton + fun provideShouldSaveAccessCodesUseCase(settingsRepository: SettingsRepository): ShouldSaveAccessCodesUseCase { + return ShouldSaveAccessCodesUseCase(settingsRepository = settingsRepository) + } // endregion } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index c280208083..a714d71200 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -156,4 +156,28 @@ internal object StakingDomainModule { stakingErrorResolver = stakingErrorResolver, ) } + + @Provides + @Singleton + fun provideIsApproveNeededUseCase( + stakingRepository: StakingRepository, + stakingErrorResolver: StakingErrorResolver, + ): IsApproveNeededUseCase { + return IsApproveNeededUseCase( + stakingRepository = stakingRepository, + stakingErrorResolver = stakingErrorResolver, + ) + } + + @Provides + @Singleton + fun provideGetConstructedStakingTransactionUseCase( + stakingRepository: StakingRepository, + stakingErrorResolver: StakingErrorResolver, + ): GetConstructedStakingTransactionUseCase { + return GetConstructedStakingTransactionUseCase( + stakingRepository = stakingRepository, + stakingErrorResolver = stakingErrorResolver, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 8af69450c8..73c0236567 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -100,4 +100,18 @@ internal object TransactionDomainModule { ): IsUtxoConsolidationAvailableUseCase { return IsUtxoConsolidationAvailableUseCase(walletManagersFacade) } + + @Provides + @Singleton + fun provideCreateApproveTransactionUseCase( + transactionRepository: TransactionRepository, + ): CreateApprovalTransactionUseCase { + return CreateApprovalTransactionUseCase(transactionRepository) + } + + @Provides + @Singleton + fun provideGetAllowanceUseCase(transactionRepository: TransactionRepository): GetAllowanceUseCase { + return GetAllowanceUseCase(transactionRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt b/app/src/main/java/com/tangem/tap/domain/TapErrors.kt index 70b2540317..1fc37a0267 100644 --- a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt +++ b/app/src/main/java/com/tangem/tap/domain/TapErrors.kt @@ -22,38 +22,14 @@ sealed class TapError( object UnknownError : TapError(R.string.send_error_unknown) open class CustomError(val customMessage: String) : TapError(R.string.common_custom_string, listOf(customMessage)) - data class UnsupportedState( - val stateError: String, - ) : TapError(R.string.common_custom_string, listOf("Unsupported state: $stateError")) object NoInternetConnection : TapError(R.string.wallet_notification_no_internet) - object AmountExceedsBalance : TapError(R.string.send_validation_amount_exceeds_balance) - data class AmountLowerExistentialDeposit( - override val args: List, - ) : TapError(R.string.send_error_minimum_balance_format) - - object FeeExceedsBalance : TapError(R.string.send_validation_invalid_fee) - object TotalExceedsBalance : TapError(R.string.send_validation_invalid_total) - object InvalidAmountValue : TapError(R.string.send_validation_invalid_amount) - object InvalidFeeValue : TapError(R.string.send_error_invalid_fee_value) - data class DustAmount(override val args: List) : TapError(R.string.send_error_dust_amount_format) - object DustChange : TapError(R.string.send_error_dust_change) sealed class WalletManager { class NoAccountError(amountToCreateAccount: String) : CustomError(amountToCreateAccount) class InternalError(message: String) : CustomError(message) object BlockchainIsUnreachableTryLater : TapError(R.string.wallet_balance_blockchain_unreachable_try_later) } - - sealed class WalletConnect { - object UnsupportedDapp : TapError(R.string.wallet_connect_error_unsupported_dapp) - object UnsupportedLink : TapError(R.string.wallet_connect_error_failed_to_connect) - } - - data class ValidateTransactionErrors( - override val errorList: List, - override val builder: (List) -> String, - ) : TapError(-1), MultiMessageError } sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) { diff --git a/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt b/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt index 64e1b31472..5f4b695932 100644 --- a/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/card/DefaultDerivationsRepository.kt @@ -8,12 +8,10 @@ import com.tangem.common.doOnSuccess import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.operations.derivation.ExtendedPublicKeysMap @@ -46,12 +44,16 @@ internal class DefaultDerivationsRepository( return } + derivePublicKeys(userWalletId = userWalletId, derivations = derivations) + } + + override suspend fun derivePublicKeys(userWalletId: UserWalletId, derivations: Derivations): DerivedKeys { tangemSdkManager.derivePublicKeys(cardId = null, derivations = derivations) .doOnSuccess { response -> updatePublicKeys(userWalletId = userWalletId, keys = response.entries) .doOnSuccess { validateDerivations(scanResponse = it.scanResponse, derivations = derivations) - return + return response.entries } .doOnFailure { throw it } } @@ -60,28 +62,6 @@ internal class DefaultDerivationsRepository( error("This code should never be reached") } - override suspend fun deriveExtendedPublicKey( - userWalletId: UserWalletId, - derivation: DerivationPath, - ): ExtendedPublicKey? { - val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("User wallet not found") - val walletCard = userWallet.scanResponse.card.wallets.firstOrNull { - UserWalletIdBuilder.scanResponse(userWallet.scanResponse).build()?.value - .contentEquals(userWallet.walletId.value) - } ?: return null - - val result = tangemSdkManager.deriveExtendedPublicKey( - cardId = null, - walletPublicKey = walletCard.publicKey, - derivation = derivation, - ) - - return when (result) { - is CompletionResult.Failure -> throw result.error - is CompletionResult.Success -> result.data - } - } - /** * It throws an exception if any of the provided derivations are invalid * Validation for NonHardened moved to application layer, to avoid fails when derive multiple paths diff --git a/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessagesManager.kt b/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessagesManager.kt deleted file mode 100644 index caa4125ff7..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/configurable/warningMessage/WarningMessagesManager.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.tap.domain.configurable.warningMessage - -import com.tangem.blockchain.common.Blockchain -import java.util.concurrent.CopyOnWriteArrayList - -/** -[REDACTED_AUTHOR] - */ -// TODO: Delete with SendFeatureToggles -@Deprecated(message = "Used only in old send screen") -class WarningMessagesManager { - - private val warningsList = CopyOnWriteArrayList() - - fun getWarnings(location: WarningMessage.Location, blockchains: List): List { - return warningsList.filter { message -> - val messageBlockchains = message.blockchainList - val isCorrespondingMessageBlockchains = messageBlockchains == null || - messageBlockchains.any(blockchains::contains) - val isCorrespondingMessageLocation = message.location.contains(location) - - !message.isHidden && isCorrespondingMessageLocation && isCorrespondingMessageBlockchains - } - } - - fun hideWarning(warning: WarningMessage): Boolean { - val foundWarning = findWarning(warning) ?: return false - val isCorrectType = foundWarning.type == WarningMessage.Type.Temporary || - foundWarning.type == WarningMessage.Type.AppRating - - return if (!foundWarning.isHidden && isCorrectType) { - foundWarning.isHidden = true - true - } else { - false - } - } - - private fun findWarning(warning: WarningMessage): WarningMessage? { - return warningsList.firstOrNull { it == warning } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt index 2901b7120b..4cad89ba5d 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt @@ -165,10 +165,10 @@ internal class GeneralUserWalletsListManager( } if (possibleManager == implementation.value) { - Timber.e("Switch to same manager ${possibleManager::class.simpleName}") + Timber.e("Switch to the same manager ${possibleManager::class.simpleName}") } - Timber.d("Switch to ${possibleManager::class.simpleName}") + Timber.i("Switch to ${possibleManager::class.simpleName}") val previousManager = implementation.value implementation.value = copySelectedUserWallet( diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt index cea126bf75..8f80642201 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt @@ -62,7 +62,7 @@ internal class DefaultLegacyWalletConnectRepository( application = application, metaData = appMetaData, ) { error -> - Timber.d("Error while initializing client: $error") + Timber.e("Error while initializing client: $error") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -73,7 +73,7 @@ internal class DefaultLegacyWalletConnectRepository( } Web3Wallet.initialize(Wallet.Params.Init(core = CoreClient)) { error -> - Timber.d("Error while initializing Web3Wallet: $error") + Timber.e("Error while initializing Web3Wallet: $error") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -94,11 +94,11 @@ internal class DefaultLegacyWalletConnectRepository( verifyContext: Wallet.Model.VerifyContext, ) { // Triggered when wallet receives the session proposal sent by a Dapp - Timber.d("sessionProposal: $sessionProposal") + Timber.i("sessionProposal: $sessionProposal") this@DefaultLegacyWalletConnectRepository.sessionProposal = sessionProposal if (sessionProposal.name in unsupportedDApps) { - Timber.w("Unsupported DApp") + Timber.i("Unsupported DApp") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -115,7 +115,7 @@ internal class DefaultLegacyWalletConnectRepository( ) if (missingNetworks.isNotEmpty()) { - Timber.w("Not added blockchains: $missingNetworks") + Timber.i("Not added blockchains: $missingNetworks") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -150,12 +150,12 @@ internal class DefaultLegacyWalletConnectRepository( verifyContext: Wallet.Model.VerifyContext, ) { // Triggered when a Dapp sends SessionRequest to sign a transaction or a message - Timber.d("sessionRequest: $sessionRequest") + Timber.i("sessionRequest: $sessionRequest") val request = wcRequestDeserializer.deserialize( method = sessionRequest.request.method, params = sessionRequest.request.params, ) - Timber.d("sessionRequestParsed: $request") + Timber.i("sessionRequestParsed: $request") when (request) { is WcRequest.AddChain -> { @@ -193,7 +193,7 @@ internal class DefaultLegacyWalletConnectRepository( verifyContext: Wallet.Model.VerifyContext, ) { // Triggered when Dapp / Requester makes an authorization request - Timber.d("onAuthRequest: $authRequest") + Timber.i("onAuthRequest: $authRequest") } override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { @@ -204,12 +204,12 @@ internal class DefaultLegacyWalletConnectRepository( updateSessionsInternal().join() } } - Timber.d("onSessionDelete: $sessionDelete") + Timber.i("onSessionDelete: $sessionDelete") } override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { // Triggered when wallet receives the session settlement response from Dapp - Timber.d("onSessionSettleResponse: $settleSessionResponse") + Timber.i("onSessionSettleResponse: $settleSessionResponse") if (settleSessionResponse is Wallet.Model.SettledSessionResponse.Result) { scope.launch { _events.emit( @@ -225,19 +225,19 @@ internal class DefaultLegacyWalletConnectRepository( override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) { // Triggered when wallet receives the session update response from Dapp - Timber.d("onSessionUpdateResponse: $sessionUpdateResponse") + Timber.i("onSessionUpdateResponse: $sessionUpdateResponse") updateSessionsInternal() } override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) { // Triggered whenever the connection state is changed - Timber.d("onConnectionStateChange: $state") + Timber.i("onConnectionStateChange: $state") if (state.isAvailable) updateSessionsInternal() } override fun onError(error: Wallet.Model.Error) { // Triggered whenever there is an issue inside the SDK - Timber.d("onError: $error") + Timber.i("onError: $error") } } } @@ -299,12 +299,12 @@ internal class DefaultLegacyWalletConnectRepository( }, ) - Timber.d("Session approval is prepared for sending: $sessionApproval") + Timber.i("Session approval is prepared for sending: $sessionApproval") Web3Wallet.approveSession( params = sessionApproval, onSuccess = { - Timber.d("Approved successfully: $it") + Timber.i("Approved successfully: $it") analyticsHandler.send( WalletConnect.NewSessionEstablished( dAppName = sessionProposal.name, @@ -313,7 +313,7 @@ internal class DefaultLegacyWalletConnectRepository( ) }, onError = { - Timber.d("Error while approving: $it") + Timber.e("Error while approving: $it") scope.launch { _events.emit( WalletConnectEvents.SessionApprovalError( @@ -370,10 +370,10 @@ internal class DefaultLegacyWalletConnectRepository( ), ), onSuccess = { response -> - Timber.d("Session request responded successfully: $response") + Timber.i("Session request responded successfully: $response") }, onError = { error -> - Timber.d(error.throwable, "Error while responging session request") + Timber.e(error.throwable, "Error while responging session request") WalletConnect.RequestHandledParams( dAppName = session?.name ?: "", @@ -427,10 +427,10 @@ internal class DefaultLegacyWalletConnectRepository( reason = "", ), onSuccess = { - Timber.d("Rejected successfully: $it") + Timber.i("Rejected successfully: $it") }, onError = { - Timber.d("Error while rejecting: $it") + Timber.e("Error while rejecting: $it") }, ) } @@ -447,10 +447,10 @@ internal class DefaultLegacyWalletConnectRepository( ), ) updateSessionsInternal() - Timber.d("Disconnected successfully: $it") + Timber.i("Disconnected successfully: $it") }, onError = { - Timber.d("Error while disconnecting: $it") + Timber.e("Error while disconnecting: $it") }, ) } @@ -483,7 +483,7 @@ internal class DefaultLegacyWalletConnectRepository( url = it.metaData?.url, ) } - Timber.d("Available sessions: $availableSessions") + Timber.i("Available sessions: $availableSessions") currentSessions = availableSessions _activeSessions.emit(availableSessions) } diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt index 1f5dce9663..122a9ed76f 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt @@ -35,7 +35,7 @@ class WalletConnectInteractor( val blockchainHelper: WcBlockchainHelper, ) { - var isWalletConnectReadyForDeepLinks = false + private var isWalletConnectReadyForDeepLinks = false private val getSelectedWalletUseCase by lazy(LazyThreadSafetyMode.NONE) { GetSelectedWalletUseCase(userWalletsListManager) @@ -82,7 +82,7 @@ class WalletConnectInteractor( private suspend fun initWithWallet(userWallet: UserWallet) { if (userWallet.isMultiCurrency) { - Timber.d("WalletConnect: initialize and setup networks for ${userWallet.walletId}") + Timber.i("WalletConnect: initialize and setup networks for ${userWallet.walletId}") startListeningWc(userWallet.walletId.stringValue, getCardId(userWallet)) subscribeOnCurrenciesUpdates(userWallet) } @@ -143,9 +143,9 @@ class WalletConnectInteractor( private suspend fun subscribeToEvents() { events .onEach { wcEvent -> + Timber.i("WalletConnect: event: $wcEvent") when (wcEvent) { is WalletConnectEvents.SessionProposal -> { - Timber.d("WC session proposal event received") val unsupportedNetworks = wcEvent.requiredChainIds .filter { blockchainHelper.chainIdToNetworkIdOrNull(it) == null } if (unsupportedNetworks.isNotEmpty()) { @@ -230,6 +230,7 @@ class WalletConnectInteractor( } fun approveSessionProposal(accounts: List) { + Timber.i("Approve session proposal: $accounts") val userNamespaces: Map> = accounts .groupBy { account -> blockchainHelper.getNamespaceFromFullChainIdOrNull(account.chainId) @@ -241,14 +242,17 @@ class WalletConnectInteractor( } fun rejectSessionProposal() { + Timber.i("Reject session proposal") walletConnectRepository.reject() } fun disconnectSession(topic: String) { + Timber.i("Disconnect session: $topic") walletConnectRepository.disconnect(topic) } fun cancelRequest(topic: String, id: Long) { + Timber.i("Cancel request: $topic, $id") walletConnectRepository.cancelRequest(topic, id) } diff --git a/app/src/main/java/com/tangem/tap/features/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/network/auth/DefaultExpressAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt index f7b18ad5a2..99ddfe6b5b 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt @@ -1,23 +1,16 @@ package com.tangem.tap.network.auth -import com.tangem.datasource.config.ConfigManager import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.lib.auth.ExpressAuthProvider -import com.tangem.lib.auth.sessionId.ExpressSessionIdGenerator import java.util.UUID import java.util.concurrent.atomic.AtomicReference internal class DefaultExpressAuthProvider( private val userWalletsStore: UserWalletsStore, - private val configManager: ConfigManager, -) : ExpressAuthProvider, ExpressSessionIdGenerator { +) : ExpressAuthProvider { private var uuid = AtomicReference(UUID.randomUUID()) - override fun getApiKey(): String { - return configManager.config.express?.apiKey ?: error("No express api key provided") - } - override fun getUserId(): String { return userWalletsStore.selectedUserWalletOrNull?.walletId?.stringValue ?: error("No user id provided") } @@ -25,8 +18,4 @@ internal class DefaultExpressAuthProvider( override fun getSessionId(): String { return uuid.get().toString() } - - override fun generateNewSessionId() { - uuid = AtomicReference(UUID.randomUUID()) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index 102598b998..41aa591911 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -29,14 +29,8 @@ class AuthModule { @Provides @Singleton - fun provideExpressAuthProvider( - userWalletsStore: UserWalletsStore, - configManager: ConfigManager, - ): ExpressAuthProvider { - return DefaultExpressAuthProvider( - userWalletsStore = userWalletsStore, - configManager = configManager, - ) + fun provideExpressAuthProvider(userWalletsStore: UserWalletsStore): ExpressAuthProvider { + return DefaultExpressAuthProvider(userWalletsStore = userWalletsStore) } @Provides diff --git a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt index cfd1fc53f2..29b6c4b9d5 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -96,18 +96,27 @@ class TransactionManagerImpl( // for not EVM blockchains set gasLimit ZERO for now when (fee.data) { is TransactionFee.Single -> { - val normalFee = (fee.data as TransactionFee.Single).normal - val singleFee = if (normalFee as? Fee.CardanoToken != null) { - ProxyFee.CardanoToken( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - minAdaValue = normalFee.minAdaValue, - ) - } else { - ProxyFee.Common( - gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = normalFee.amount), - ) + val singleFee = when (val normalFee = (fee.data as TransactionFee.Single).normal) { + is Fee.CardanoToken -> { + ProxyFee.CardanoToken( + gasLimit = BigInteger.ZERO, + fee = convertToProxyAmount(amount = normalFee.amount), + minAdaValue = normalFee.minAdaValue, + ) + } + is Fee.Filecoin -> { + ProxyFee.Filecoin( + gasLimit = BigInteger.ZERO, + fee = convertToProxyAmount(amount = normalFee.amount), + gasPremium = normalFee.gasPremium, + ) + } + else -> { + ProxyFee.Common( + gasLimit = BigInteger.ZERO, + fee = convertToProxyAmount(amount = normalFee.amount), + ) + } } ProxyFees.SingleFee(singleFee = singleFee) diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index ee435bc6ec..45b2035717 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -32,7 +32,6 @@ import com.tangem.feature.qrscanning.QrScanningRouter import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter -import com.tangem.features.send.api.featuretoggles.SendFeatureToggles import com.tangem.features.send.api.navigation.SendRouter import com.tangem.features.staking.api.navigation.StakingRouter import com.tangem.features.tester.api.TesterRouter @@ -64,7 +63,6 @@ data class DaggerGraphState( val balanceHidingRepository: BalanceHidingRepository? = null, val walletsRepository: WalletsRepository? = null, val networksRepository: NetworksRepository? = null, - val sendFeatureToggles: SendFeatureToggles? = null, val sendRouter: SendRouter? = null, val qrScanningRouter: QrScanningRouter? = null, val currenciesRepository: CurrenciesRepository? = null, diff --git a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt index aca4ce8efb..592656008e 100644 --- a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt +++ b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt @@ -7,6 +7,7 @@ import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import timber.log.Timber import kotlin.reflect.KClass internal class ProxyAppRouter( @@ -31,30 +32,35 @@ internal class ProxyAppRouter( override fun push(route: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Push route: $route") innerRouter.push(route, onComplete) } } override fun replaceAll(vararg routes: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Replace all routes with $routes") innerRouter.replaceAll(*routes, onComplete = onComplete) } } override fun pop(onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Pop route") innerRouter.pop(onComplete) } } override fun popTo(route: AppRoute, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Pop to route: $route") innerRouter.popTo(route, onComplete) } } override fun popTo(routeClass: KClass, onComplete: (isSuccess: Boolean) -> Unit) { routerScope.launch(dispatchers.mainImmediate) { + Timber.i("Pop to route class: $routeClass") innerRouter.popTo(routeClass, onComplete) } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 8b3c22a7f9..af1bebc286 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -9,6 +9,8 @@ import com.tangem.feature.walletsettings.component.WalletSettingsComponent import com.tangem.features.details.DetailsFeatureToggles import com.tangem.features.details.component.DetailsComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent +import com.tangem.features.managetokens.ManageTokensToggles +import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.pushnotifications.api.featuretoggles.PushNotificationsFeatureToggles import com.tangem.features.pushnotifications.api.navigation.PushNotificationsRouter import com.tangem.features.send.api.navigation.SendRouter @@ -47,6 +49,7 @@ internal class ChildFactory @Inject constructor( private val detailsComponentFactory: DetailsComponent.Factory, private val walletSettingsComponentFactory: WalletSettingsComponent.Factory, private val disclaimerComponentFactory: DisclaimerComponent.Factory, + private val manageTokensComponentFactory: ManageTokensComponent.Factory, private val sendRouter: SendRouter, private val tokenDetailsRouter: TokenDetailsRouter, private val walletRouter: WalletRouter, @@ -55,6 +58,7 @@ internal class ChildFactory @Inject constructor( private val testerRouter: TesterRouter, private val detailsFeatureToggles: DetailsFeatureToggles, private val pushNotificationsFeatureToggles: PushNotificationsFeatureToggles, + private val manageTokensToggles: ManageTokensToggles, private val pushNotificationRouter: PushNotificationsRouter, ) { @@ -119,7 +123,21 @@ internal class ChildFactory @Inject constructor( route.asFragmentChild(Provider { HomeFragment() }) } is AppRoute.ManageTokens -> { - route.asFragmentChild(Provider { TokensListFragment() }) + if (manageTokensToggles.isFeatureEnabled) { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = ManageTokensComponent.Params( + mode = if (route.readOnlyContent) { + ManageTokensComponent.Mode.READ_ONLY + } else { + ManageTokensComponent.Mode.MANAGE + }, + ), + componentFactory = manageTokensComponentFactory, + ) + } else { + route.asFragmentChild(Provider { TokensListFragment() }) + } } is AppRoute.OnboardingNote -> { route.asFragmentChild(Provider { OnboardingNoteFragment() }) diff --git a/app/src/main/res/layout/fragment_send.xml b/app/src/main/res/layout/fragment_send.xml deleted file mode 100644 index a7f8c54c42..0000000000 --- a/app/src/main/res/layout/fragment_send.xml +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/layout_send_address.xml b/app/src/main/res/layout/layout_send_address.xml deleted file mode 100644 index 1256f33037..0000000000 --- a/app/src/main/res/layout/layout_send_address.xml +++ /dev/null @@ -1,329 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/layout_send_amount.xml b/app/src/main/res/layout/layout_send_amount.xml deleted file mode 100644 index b8f987e234..0000000000 --- a/app/src/main/res/layout/layout_send_amount.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_send_fee.xml b/app/src/main/res/layout/layout_send_fee.xml deleted file mode 100644 index 66c3b128f1..0000000000 --- a/app/src/main/res/layout/layout_send_fee.xml +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_send_receipt.xml b/app/src/main/res/layout/layout_send_receipt.xml deleted file mode 100644 index 97e7afbb0f..0000000000 --- a/app/src/main/res/layout/layout_send_receipt.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_snackbar_max_amount.xml b/app/src/main/res/layout/view_snackbar_max_amount.xml deleted file mode 100644 index 141e31aa66..0000000000 --- a/app/src/main/res/layout/view_snackbar_max_amount.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_snackbar_max_amount_content.xml b/app/src/main/res/layout/view_snackbar_max_amount_content.xml deleted file mode 100644 index de8cdfa27e..0000000000 --- a/app/src/main/res/layout/view_snackbar_max_amount_content.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index d92b770060..e71d217293 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -94,6 +94,11 @@ val generateComposeMetrics by tasks.registering { "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$outputDirectory", ) + // Compose strong skipping mode + // freeCompilerArgs.addAll( + // "-P", + // "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true", + // ) } } } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 6cd73dfcb0..6c03778967 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -176,7 +176,11 @@ sealed class AppRoute(val path: String) : Route { } @Serializable - data object ManageTokens : AppRoute(path = "/manage_tokens") + data class ManageTokens( + val readOnlyContent: Boolean, + ) : AppRoute(path = "/manage_tokens/$readOnlyContent"), RouteBundleParams { + override fun getBundle(): Bundle = bundle(serializer()) + } @Serializable data object AddCustomToken : AppRoute(path = "/add_custom_token") diff --git a/common/ui-charts/build.gradle.kts b/common/ui-charts/build.gradle.kts index 319c15eebc..58bf27c3a5 100644 --- a/common/ui-charts/build.gradle.kts +++ b/common/ui-charts/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { implementation(deps.compose.material3) implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) + implementation(deps.kotlin.immutable.collections) } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt index 9bbaeaa48b..cd14d8f8c8 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt @@ -3,7 +3,6 @@ package com.tangem.common.ui.charts import android.content.res.Configuration import androidx.annotation.FloatRange import androidx.compose.foundation.background -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -11,45 +10,55 @@ import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontSynthesis import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.resolveAsTypeface import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.patrykandpatrick.vico.compose.cartesian.* +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis import com.patrykandpatrick.vico.compose.cartesian.axis.rememberCustomStartAxis +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState import com.patrykandpatrick.vico.compose.common.of import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom -import com.patrykandpatrick.vico.core.cartesian.axis.* +import com.patrykandpatrick.vico.core.cartesian.axis.AxisPosition +import com.patrykandpatrick.vico.core.cartesian.axis.BaseAxis +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter -import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget import com.patrykandpatrick.vico.core.common.Dimensions import com.patrykandpatrick.vico.core.common.component.LineComponent import com.patrykandpatrick.vico.core.common.shape.Shape +import com.tangem.common.ui.charts.layer.TimeItemPlacer import com.tangem.common.ui.charts.layer.rememberMarketChartLayer -import com.tangem.common.ui.charts.marker.rememberTangemChartMarker +import com.tangem.common.ui.charts.layer.rememberTangemChartMarker import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider import com.tangem.common.ui.charts.state.* import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toTimeFormat +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode @@ -64,79 +73,92 @@ private const val GUIDELINES_COUNT = 3 * @param splitChartSegmentColor The color of the grayed by marker chart segment. * @param backgroundSplitChartSegmentColorAlpha The alpha of the background the [splitChartSegmentColor] * @param backgroundColorAlpha The alpha of the background color of the chart. - * @param noChartContent A composable function that defines the content to be displayed when there is no data to display. */ @Composable fun MarketChart( modifier: Modifier = Modifier, state: MarketChartState = rememberMarketChartState(), - splitChartSegmentColor: Color, - @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float, - @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float, - noChartContent: @Composable BoxScope.() -> Unit, + splitChartSegmentColor: Color = TangemTheme.colors.icon.inactive, + @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float = 0.24f, + @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float = 0.24f, ) { var canvasWidth by remember { mutableIntStateOf(0) } - var canvasHeight by remember { mutableIntStateOf(0) } + var chartHeight by remember { mutableIntStateOf(0) } - val layer = rememberLayerFromState( - state = state, - splitChartSegmentColor = splitChartSegmentColor, - backgroundColorAlpha = backgroundColorAlpha, - backgroundSplitChartSegmentColorAlpha = backgroundSplitChartSegmentColorAlpha, - canvasHeight = canvasHeight, + val layer = rememberMarketChartLayer( + lineColor = state.chartColor, + backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha), + secondLineColor = splitChartSegmentColor, + backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha), + secondColorOnTheRightSide = state.markerHighlightRightSide.not(), + markerFraction = state.markerFraction, + axisValueOverrider = AxisValueOverrider.fixed(), + canvasHeight = chartHeight, ) + + val marker = rememberTangemChartMarker(color = state.chartColor) + val chart = rememberCartesianChart( layer, - startAxis = rememberMarketChartStartAxis( - yValueFormatter = state.yValueFormatter, - ), - bottomAxis = rememberMarketChartBottomAxis( - xValueFormatter = state.xValueFormatter, - ), + startAxis = rememberMarketChartStartAxis(state.yValueFormatter), + bottomAxis = rememberMarketChartBottomAxis(state.xValueFormatter), + horizontalLayout = HorizontalLayout.FullWidth(), + markerVisibilityListener = rememberMarketVisibilityListener(canvasWidth, state), + marker = marker, ) - val marker = rememberTangemChartMarker( - color = state.chartColor, - innerCircleColor = Color.White, - ) - val density = LocalDensity.current + + // we need to calculate what the overall height should be in order to get the correct height of the graph + val bottomAxisHeight = with(LocalDensity.current) { + getMarketChartBottomAxisHeight().toPx().toInt() + } CartesianChartHost( - modifier = modifier.onGloballyPositioned { - with(density) { + modifier = modifier + .onGloballyPositioned { canvasWidth = it.size.width - canvasHeight = if (it.size.height != 0) { - // FIXME get height bounded to min max chart points - it.size.height - 20.dp.toPx().toInt() - 27.dp.toPx().toInt() + chartHeight = if (it.size.height != 0) { + it.size.height - bottomAxisHeight } else { 0 } } - }, + // Sometimes the chart is not drawn correctly (ex. in LazyLayout), so we need to force the redraw + .drawBehind { + state.markerFraction + }, chart = chart, modelProducer = state.modelProducer, scrollState = rememberVicoScrollState(scrollEnabled = false), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - markerVisibilityListener = state.rememberMarketVisibilityListener(canvasWidth = canvasWidth), - diffAnimationSpec = null, - marker = marker, - placeholder = noChartContent, + animationSpec = null, ) } @Composable -private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int): CartesianMarkerVisibilityListener { - val state = this - return remember(state.markerVisibilityListener, canvasWidth) { +fun getMarketChartBottomAxisHeight(): Dp { + return with(LocalDensity.current) { + TangemTheme.typography.caption2.fontSize.toDp() + TangemTheme.dimens.spacing26 + } +} + +@Composable +private fun rememberMarketVisibilityListener( + canvasWidth: Int, + state: MarketChartState, +): CartesianMarkerVisibilityListener { + val haptic = LocalHapticManager.current + + return remember(state, canvasWidth) { val maxCanvasXFloat = canvasWidth.toFloat().takeIf { it != 0f } object : CartesianMarkerVisibilityListener { override fun onShown(marker: CartesianMarker, targets: List) { - state.stopDrawingAnimation() val xCanvas = (targets[0] as LineCartesianLayerMarkerTarget).canvasX state.markerFraction = maxCanvasXFloat?.let { xCanvas / it } state.markerVisibilityListener.onShown(marker, targets) + + haptic.perform(TangemHapticEffect.View.ContextClick) } override fun onHidden(marker: CartesianMarker) { @@ -149,38 +171,30 @@ private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int): state.markerFraction = maxCanvasXFloat?.let { xCanvas / it } state.markerVisibilityListener.onUpdated(marker, targets) + + haptic.perform(TangemHapticEffect.View.TextHandleMove) } } } } -@Composable -private fun rememberLayerFromState( - state: MarketChartState, - splitChartSegmentColor: Color, - @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float, - @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float, - canvasHeight: Int, -): LineCartesianLayer { - return rememberMarketChartLayer( - lineColor = state.chartColor, - backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha), - secondLineColor = splitChartSegmentColor, - backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha), - secondColorOnTheRightSide = state.markerHighlightRightSide.not(), - startDrawingAnimation = state.startDrawingAnimationState, - markerFraction = state.markerFraction, - axisValueOverrider = AxisValueOverrider.adaptiveYValues(yFraction = 1.2f, round = true), // FIXME ? - canvasHeight = canvasHeight, - ) -} - @Composable private fun rememberMarketChartStartAxis( yValueFormatter: CartesianValueFormatter, ): VerticalAxis { + val textStyle = TangemTheme.typography.caption2 + val resolver = LocalFontFamilyResolver.current + val typeface by remember(resolver, textStyle) { + resolver.resolveAsTypeface( + fontFamily = textStyle.fontFamily, + fontWeight = textStyle.fontWeight ?: FontWeight.Normal, + fontStyle = textStyle.fontStyle ?: FontStyle.Normal, + fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All, + ) + } + return rememberCustomStartAxis( - axis = null, + line = null, tick = null, guideline = null, labelGuideline = rememberChartAxisGuidelineComponent( @@ -194,38 +208,44 @@ private fun rememberMarketChartStartAxis( end = TangemTheme.dimens.spacing4, ), textSize = TangemTheme.typography.caption2.fontSize, - typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(), + typeface = typeface, ), horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Inside, verticalLabelPosition = VerticalAxis.VerticalLabelPosition.Center, - itemPlacer = AxisItemPlacer.Vertical.count({ GUIDELINES_COUNT }, false), + itemPlacer = VerticalAxis.ItemPlacer.count({ GUIDELINES_COUNT }, false), valueFormatter = yValueFormatter, ) } @Composable -fun rememberMarketChartBottomAxis( +private fun rememberMarketChartBottomAxis( xValueFormatter: CartesianValueFormatter, ): HorizontalAxis { + val textStyle = TangemTheme.typography.caption2 + + val resolver = LocalFontFamilyResolver.current + + val typeface by remember(resolver, textStyle) { + resolver.resolveAsTypeface( + fontFamily = textStyle.fontFamily, + fontWeight = textStyle.fontWeight ?: FontWeight.Normal, + fontStyle = textStyle.fontStyle ?: FontStyle.Normal, + fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All, + ) + } + return rememberBottomAxis( label = rememberAxisLabelComponent( color = TangemTheme.colors.text.tertiary, textSize = TangemTheme.typography.caption2.fontSize, - padding = Dimensions.of(top = TangemTheme.dimens.spacing20), - typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(), + padding = Dimensions.of(top = TangemTheme.dimens.spacing26), + typeface = typeface, ), tick = null, - axis = null, + line = null, guideline = null, - sizeConstraint = BaseAxis.SizeConstraint.Exact(sizeDp = 37f), // FIXME ? - itemPlacer = remember { - AxisItemPlacer.Horizontal.default( - spacing = 25, // FIXME ? - offset = 60, // FIXME ? - shiftExtremeTicks = false, - addExtremeLabelPadding = false, - ) - }, + sizeConstraint = BaseAxis.SizeConstraint.Auto(), + itemPlacer = remember { TimeItemPlacer() }, valueFormatter = xValueFormatter, ) } @@ -245,19 +265,6 @@ private fun rememberChartAxisGuidelineComponent(color: Color): LineComponent { ) } -@Composable -internal fun TextStyle.toGraphicsTypeFace(): android.graphics.Typeface { - val resolver = LocalFontFamilyResolver.current - return remember(resolver, this) { - resolver.resolveAsTypeface( - fontFamily = this.fontFamily, - fontWeight = this.fontWeight ?: FontWeight.Normal, - fontStyle = this.fontStyle ?: FontStyle.Normal, - fontSynthesis = this.fontSynthesis ?: FontSynthesis.All, - ) - }.value -} - // region Preview @Suppress("LongMethod") @@ -275,7 +282,6 @@ private fun MarketChartPreview( chartLook = MarketChartLook( type = MarketChartLook.Type.Growing, markerHighlightRightSide = true, - animationOnDataChange = true, ) } } @@ -283,13 +289,13 @@ private fun MarketChartPreview( LaunchedEffect(key1 = Unit) { dataProducer.runTransactionSuspend { chartData = MarketChartData.Data( - x = x, - y = y, + x = x.toImmutableList(), + y = y.toImmutableList(), ) updateLook { it.copy( xAxisFormatter = { value -> - value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd) + value.toLong().toTimeFormat(DateTimeFormatters.dateMMMdd) }, yAxisFormatter = { value -> value.setScale(3, RoundingMode.HALF_UP).toPlainString() @@ -308,6 +314,7 @@ private fun MarketChartPreview( TangemThemePreview { val growingColor = TangemTheme.colors.icon.accent val fallingColor = TangemTheme.colors.icon.warning + val neutralColor = TangemTheme.colors.icon.informative val chartState = rememberMarketChartState( dataProducer = dataProducer, @@ -318,6 +325,7 @@ private fun MarketChartPreview( when (it) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor + MarketChartLook.Type.Neutral -> neutralColor } }, ) @@ -338,13 +346,9 @@ private fun MarketChartPreview( splitChartSegmentColor = TangemTheme.colors.icon.inactive, backgroundSplitChartSegmentColorAlpha = 0.24f, backgroundColorAlpha = 0.24f, - noChartContent = { }, ) SpacerH16() - Button(onClick = { chartState.startDrawingAnimation() }) { - Text("Start drawing animation") - } Button( onClick = { dataProducer.runTransaction { @@ -358,41 +362,38 @@ private fun MarketChartPreview( text = "Change marker highlight side", ) } - Button(onClick = { - coroutineScope.launch { - dataProducer.runTransactionSuspend { - updateData { - MarketChartData.Data( - x = it.x, - y = it.y.reversed(), + Button( + onClick = { + coroutineScope.launch { + dataProducer.runTransactionSuspend { + updateData { + MarketChartData.Data( + x = it.x, + y = it.y.reversed().toImmutableList(), + ) + } + } + } + }, + ) { + Text("Change Data") + } + + Button( + onClick = { + dataProducer.runTransaction { + updateLook { + it.copy( + type = when (it.type) { + MarketChartLook.Type.Growing -> MarketChartLook.Type.Falling + MarketChartLook.Type.Falling -> MarketChartLook.Type.Neutral + MarketChartLook.Type.Neutral -> MarketChartLook.Type.Growing + }, ) } } - } - },) { - Text("Change Data") - } - Button(onClick = { - dataProducer.runTransaction { - updateLook { it.copy(animationOnDataChange = it.animationOnDataChange.not()) } - } - },) { - Text("Change animationOnDataChange = ${look.animationOnDataChange}") - } - - Button(onClick = { - dataProducer.runTransaction { - updateLook { - it.copy( - type = if (it.type == MarketChartLook.Type.Growing) { - MarketChartLook.Type.Falling - } else { - MarketChartLook.Type.Growing - }, - ) - } - } - },) { + }, + ) { Text("Change color type") } } diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt index d9902450d3..2e62078f0f 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt @@ -12,19 +12,21 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.* +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.common.shader.BrushShader +import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.common.shader.ColorShader import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.toImmutableList import kotlin.random.Random @Composable @@ -34,6 +36,7 @@ fun MarketChartMini( type: MarketChartLook.Type = MarketChartLook.Type.Growing, growingColor: Color = TangemTheme.colors.icon.accent, fallingColor: Color = TangemTheme.colors.icon.warning, + neutralColor: Color = TangemTheme.colors.icon.informative, ) { val model = remember(rawData) { CartesianChartModel(LineCartesianLayerModel.build { series(rawData.y) }) @@ -42,20 +45,22 @@ fun MarketChartMini( val lineColor = when (type) { MarketChartLook.Type.Growing -> growingColor MarketChartLook.Type.Falling -> fallingColor + MarketChartLook.Type.Neutral -> neutralColor } - val lineSpec = rememberLineSpec( + val lineSpec = rememberLine( shader = ColorShader(lineColor.toArgb()), thickness = 1.dp, - backgroundShader = BrushShader( - brush = Brush.verticalGradient( - colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent), - ), - ), + backgroundShader = Brush.verticalGradient( + colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent), + ).toDynamicShader(), ) - val layer = rememberLineCartesianLayer(listOf(lineSpec)) - val chart = rememberCartesianChart(layer) + val layer = rememberLineCartesianLayer(LineCartesianLayer.LineProvider.series(lineSpec)) + val chart = rememberCartesianChart( + layer, + horizontalLayout = HorizontalLayout.fullWidth(), + ) CartesianChartHost( modifier = modifier, @@ -63,7 +68,6 @@ fun MarketChartMini( model = model, zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), scrollState = rememberVicoScrollState(scrollEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), ) } @@ -74,8 +78,8 @@ fun MarketChartMini( @Composable private fun Preview() { val data = MarketChartRawData( - x = List(20) { Random.nextFloat() }, - y = List(20) { Random.nextFloat() }, + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), ) TangemThemePreview { @@ -83,6 +87,8 @@ private fun Preview() { MarketChartMini(rawData = data, type = MarketChartLook.Type.Growing) SpacerH16() MarketChartMini(rawData = data, type = MarketChartLook.Type.Falling) + SpacerH16() + MarketChartMini(rawData = data, type = MarketChartLook.Type.Neutral) } } } @@ -92,8 +98,8 @@ private fun Preview() { @Composable private fun PreviewColumn() { val data = MarketChartRawData( - x = List(20) { Random.nextFloat() }, - y = List(20) { Random.nextFloat() }, + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), ) TangemThemePreview { diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt new file mode 100644 index 0000000000..21578b9cc1 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt @@ -0,0 +1,246 @@ +package com.tangem.common.ui.charts.downsample + +import kotlin.math.max + +/** + * ========================================================= + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= + * + * Downsamples the given data points to the desired number of buckets (points + 2). + * +[REDACTED_AUTHOR] + */ +object LTThreeBuckets { + + fun downsample(x: List, y: List, desiredBuckets: Int): Result { + require(x.size == y.size) { "X and Y must have the same size" } + require(desiredBuckets > 0) { "Desired buckets must be greater than 0" } + + val points = x.zip(y).mapIndexed { index, (x, y) -> Point(index, x, y) } + val results = mutableListOf() + + points.onPassBucketize(desiredBuckets) + .sliding(size = 3, step = 1) + .map { buckets -> Triangle.of(buckets) } + .fastForEach { triangle -> + if (results.isEmpty()) { + results.add(triangle.getFirst()) + } + + results.add(triangle.getResult()) + + if (results.size == desiredBuckets + 1) { + results.add(triangle.getLast()) + } + } + + val xRes = ArrayList(points.size) + val yRes = ArrayList(points.size) + val indexesRes = ArrayList(points.size) + + results.fastForEach { + xRes.add(it.x) + yRes.add(it.y) + indexesRes.add(it.originalIndex!!) + } + + return Result( + originalIndexes = indexesRes, + x = xRes, + y = yRes, + ) + } + + data class Result( + val originalIndexes: List, + val x: List, + val y: List, + ) +} + +private fun List.onPassBucketize(desiredBucketsCount: Int): List { + val middleSize = size - 2 + val bucketSize = middleSize / desiredBucketsCount + val remainingElements = middleSize % desiredBucketsCount + + require(bucketSize != 0) { + "Can't produce $desiredBucketsCount buckets from an input series of ${middleSize + 2} elements" + } + + val buckets = mutableListOf() + + // Add first point as the only point in the first bucket + buckets.add(Bucket.of(this[0])) + + var rest = this.subList(1, this.lastIndex) + + // Add middle buckets. + // When inputSize is not a multiple of desiredBuckets, + // remaining elements are equally distributed on the first buckets. + while (buckets.size < desiredBucketsCount + 1) { + val size = if (buckets.size <= remainingElements) bucketSize + 1 else bucketSize + buckets.add(Bucket.of(rest.subList(0, size))) + rest = rest.subList(size, rest.size) + } + + // Add last point as the only point in the last bucket + buckets.add(Bucket.of(this.last())) + + return buckets +} + +private fun List.sliding(size: Int, step: Int): List> { + val window = max(size, step) + val buffer = ArrayDeque() + var totalIn = 0 + + val lists = mutableListOf>() + + fastForEach { p -> + buffer.add(p) + ++totalIn + if (buffer.size == window) { + val batch = buffer.take(size) + lists.add(batch) + + repeat(step) { + buffer.removeFirst() + } + } + } + + if (buffer.isNotEmpty()) { + val totalOut = max(0, (totalIn + step - size - 1) / step) + 1 + if (totalOut > lists.size) { + val batch = buffer.take(size) + lists.add(batch) + } + } + + return lists +} + +private data class Point( + val originalIndex: Int? = null, + val x: Double, + val y: Double, +) + +private data class Bucket( + val data: List, + val center: Point, + val result: Point, + val first: Point, + val last: Point, +) { + companion object { + private fun centerBetweenPoints(a: Point, b: Point): Point { + val vector = Point( + x = b.x - a.x, + y = b.y - a.y, + ) + val halfVector = Point( + x = vector.x / 2, + y = vector.y / 2, + ) + + return Point( + x = a.x + halfVector.x, + y = a.y + halfVector.y, + ) + } + + fun of(points: List): Bucket { + val first = points.first() + val last = points.last() + + return Bucket( + data = points, + center = centerBetweenPoints(first, last), + result = first, + first = first, + last = last, + ) + } + + fun of(point: Point): Bucket { + return Bucket( + data = listOf(point), + center = point, + result = point, + first = point, + last = point, + ) + } + } +} + +private data class Triangle( + val left: Bucket, + val center: Bucket, + val right: Bucket, +) { + fun getResult(): Point { + return center.data.map { Area.ofTriangle(left.result, it, right.center) } + .maxByOrNull { it.value } + ?.generator + ?: error("Can't obtain max area triangle") + } + + fun getFirst(): Point { + return left.first + } + + fun getLast(): Point { + return right.last + } + + companion object { + fun of(buckets: List): Triangle { + return Triangle( + left = buckets[0], + center = buckets[1], + right = buckets[2], + ) + } + } +} + +private data class Area( + val generator: Point, + val value: Double, +) { + companion object { + fun ofTriangle(a: Point, b: Point, c: Point): Area { + val addends = listOf( + a.x * (b.y - c.y), + b.x * (c.y - a.y), + c.x * (a.y - b.y), + ) + val sum = addends.sum() + val value = kotlin.math.abs(sum / 2) + + return Area(b, value) + } + } +} + +private inline fun List.fastForEach(action: (T) -> Unit) { + for (index in indices) { + val item = get(index) + action(item) + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt new file mode 100644 index 0000000000..0005e47d15 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt @@ -0,0 +1,112 @@ +package com.tangem.common.ui.charts.layer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent +import com.patrykandpatrick.vico.compose.common.component.shapeComponent +import com.patrykandpatrick.vico.compose.common.of +import com.patrykandpatrick.vico.compose.common.shape.dashed +import com.patrykandpatrick.vico.core.cartesian.* +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerValueFormatter +import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker +import com.patrykandpatrick.vico.core.common.Dimensions +import com.patrykandpatrick.vico.core.common.LayeredComponent +import com.patrykandpatrick.vico.core.common.component.Component +import com.patrykandpatrick.vico.core.common.component.TextComponent +import com.patrykandpatrick.vico.core.common.shape.Shape +import com.tangem.core.ui.res.TangemTheme + +/** + * @param color The color of the indicator and guideline. + * @param innerCircleColor The color of the inner circle of the indicator. + * + * @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect. + */ +@Composable +internal fun rememberTangemChartMarker(color: Color): CartesianMarker { + val guideline = rememberUnboundedLineComponent( + color = color, + verticalAddDrawSpace = TangemTheme.dimens.spacing24, + shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) }, + ) + + return remember(guideline) { + val outColor = guideline.color + + object : DefaultCartesianMarker( + label = TextComponent(textSizeSp = 0f), + indicator = ::indicator, + indicatorSizeDp = INDICATOR_SIZE_DP, + guideline = guideline, + valueFormatter = object : CartesianMarkerValueFormatter { + override fun format( + context: CartesianDrawContext, + targets: List, + ): CharSequence = "" + }, + ) { + override fun updateInsets( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + model: CartesianChartModel, + insets: Insets, + ) { + with(context) { + super.updateInsets(context, horizontalDimensions, model, insets) + val baseShadowInsetDp = + CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP + val topInset = (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels + val bottomInset = (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels + insets.ensureValuesAtLeast(top = topInset, bottom = bottomInset) + } + } + + override fun CartesianDrawContext.drawIndicator(x: Float, y: Float, color: Int, halfIndicatorSize: Float) { + val indicator = indicator ?: return + cacheStore + .getOrSet(keyNamespace, indicator, outColor) { indicator.invoke(outColor) } + .draw( + this, + x - halfIndicatorSize, + y - halfIndicatorSize, + x + halfIndicatorSize, + y + halfIndicatorSize, + ) + } + } + } +} + +private fun indicator(color: Int): Component { + val composeColor = Color(color) + + return LayeredComponent( + rear = shapeComponent( + color = composeColor.copy(alpha = INDICATOR_REAR_COLOR_ALPHA), + shape = Shape.Pill, + ), + front = LayeredComponent( + rear = shapeComponent( + color = composeColor, + shape = Shape.Pill, + ), + front = shapeComponent( + color = Color.White, + shape = Shape.Pill, + ), + padding = indicatorPadding, + ), + padding = indicatorPadding, + ) +} + +private val indicatorPadding = Dimensions.of(3.dp) +private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f +private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f +private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f +private const val INDICATOR_SIZE_DP = 16f +private const val INDICATOR_REAR_COLOR_ALPHA = .24f \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt index b224a1838d..a01ceb3d22 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt @@ -2,9 +2,6 @@ package com.tangem.common.ui.charts.layer import android.content.res.Configuration import androidx.annotation.FloatRange -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,22 +17,22 @@ import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.fullWidth import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLineSpec +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLine import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState -import com.patrykandpatrick.vico.compose.common.shader.BrushShader +import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.core.common.shader.ColorShader import com.patrykandpatrick.vico.core.common.shader.DynamicShader import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal /** @@ -59,117 +56,50 @@ internal fun rememberMarketChartLayer( backgroundLineColor: Color, secondLineColor: Color, backgroundSecondLineColor: Color, - startDrawingAnimation: MutableState, axisValueOverrider: AxisValueOverrider, secondColorOnTheRightSide: Boolean, @FloatRange(from = 0.0, to = 1.0) markerFraction: Float?, canvasHeight: Int, ): LineCartesianLayer { - var animationFraction: Float? by remember { mutableStateOf(null) } + val backgroundColorLineGradient = persistentListOf(backgroundLineColor, Color.Transparent) + val backgroundSecondLineColorGradient = persistentListOf(backgroundSecondLineColor, Color.Transparent) - LaunchedEffect(startDrawingAnimation.value) { - animationFraction = null - if (startDrawingAnimation.value) { - animate( - initialValue = 0f, - targetValue = 1f, - animationSpec = tween(easing = LinearEasing, durationMillis = 1000), - ) { start, _ -> - if (start == 1f) { - animationFraction = null - startDrawingAnimation.value = false - } else { - animationFraction = start - } - } - } - } + val markerSet = markerFraction != null - return rememberRawMarketChartLayer( - lineColor = lineColor, - backgroundLineColor = backgroundLineColor, - secondLineColor = secondLineColor, - backgroundSecondLineColor = backgroundSecondLineColor, + return rememberLayer( + fractionValue = markerFraction ?: 0f, axisValueOverrider = axisValueOverrider, - secondColorOnTheRightSide = secondColorOnTheRightSide, - markerFraction = markerFraction, - animationFraction = animationFraction, canvasHeight = canvasHeight, + lineColor = if (markerFraction != null) { + secondLineColor + } else { + lineColor + }, + backLineColor = if (markerSet && !secondColorOnTheRightSide) { + backgroundSecondLineColorGradient + } else { + backgroundColorLineGradient + }, + lineColorRight = when { + markerSet && secondColorOnTheRightSide -> secondLineColor + else -> lineColor + }, + backLineColorRight = when { + markerSet && secondColorOnTheRightSide -> backgroundSecondLineColorGradient + else -> backgroundColorLineGradient + }, ) } @Suppress("LongParameterList") -@Composable -private fun rememberRawMarketChartLayer( - lineColor: Color, - backgroundLineColor: Color, - secondLineColor: Color, - backgroundSecondLineColor: Color, - axisValueOverrider: AxisValueOverrider, - canvasHeight: Int, - secondColorOnTheRightSide: Boolean = false, - @FloatRange(from = 0.0, to = 1.0) markerFraction: Float? = null, - @FloatRange(from = 0.0, to = 1.0) animationFraction: Float? = null, -): LineCartesianLayer { - val backgroundColorLineGradient = listOf(backgroundLineColor, Color.Transparent) - val backgroundSecondLineColorGradient = listOf(backgroundSecondLineColor, Color.Transparent) - - val markerSet = markerFraction != null - val animationRunning = animationFraction != null && animationFraction != 1f - - val layerColors = when { - !animationRunning && markerSet && secondColorOnTheRightSide -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - lineColorRight = secondLineColor, - backLineColorRight = backgroundSecondLineColorGradient, - ) - } - !animationRunning && markerSet && !secondColorOnTheRightSide -> { - LayerColors( - lineColor = secondLineColor, - backLineColor = backgroundSecondLineColorGradient, - lineColorRight = lineColor, - backLineColorRight = backgroundColorLineGradient, - ) - } - animationRunning -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - lineColorRight = Color.Transparent, - backLineColorRight = listOf(Color.Transparent, Color.Transparent), - ) - } - else -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - ) - } - } - - return rememberLayer( - fractionValue = animationFraction ?: markerFraction, - axisValueOverrider = axisValueOverrider, - layerColors = layerColors, - canvasHeight = canvasHeight, - ) -} - -private data class LayerColors( - val lineColor: Color, - val backLineColor: List, - val lineColorRight: Color? = null, - val backLineColorRight: List? = null, -) - @Composable private fun rememberLayer( - fractionValue: Float?, + fractionValue: Float, axisValueOverrider: AxisValueOverrider, - layerColors: LayerColors, + lineColor: Color, + backLineColor: ImmutableList, + lineColorRight: Color, + backLineColorRight: ImmutableList, canvasHeight: Int, ): LineCartesianLayer { val endGradientColorPosition = if (canvasHeight != 0) { @@ -178,47 +108,27 @@ private fun rememberLayer( Float.POSITIVE_INFINITY } + val alineColor = remember(lineColor) { lineColor.toArgb() } + val alineColorRight = remember(lineColorRight) { lineColorRight.toArgb() } + return rememberLineCartesianLayer( - listOf( - if (layerColors.lineColorRight == null || layerColors.backLineColorRight == null || fractionValue == null) { - rememberLineSpec( - shader = remember(layerColors.lineColor) { ColorShader(color = layerColors.lineColor.toArgb()) }, - backgroundShader = remember(layerColors.backLineColor, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColor, - endY = endGradientColorPosition, - ), - ) - }, - ) - } else { - rememberSplitLineSpec( - shader = remember(layerColors.lineColor, layerColors.lineColorRight, fractionValue) { - DynamicShader.Companion.horizontalGradient( - colors = intArrayOf(layerColors.lineColor.toArgb(), layerColors.lineColorRight.toArgb()), - positions = floatArrayOf(fractionValue, fractionValue), - ) - }, - backgroundShaderFirst = remember(layerColors.backLineColor, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColor, - endY = endGradientColorPosition, - ), - ) - }, - backgroundShaderSecond = remember(layerColors.backLineColorRight, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColorRight, - endY = endGradientColorPosition, - ), - ) - }, - xSplitFraction = fractionValue, - ) - }, + LineCartesianLayer.LineProvider.series( + rememberSplitLine( + shader = DynamicShader.Companion.horizontalGradient( + colors = intArrayOf(alineColor, alineColorRight), + positions = floatArrayOf(fractionValue, fractionValue), + ), + backgroundShaderFirst = Brush.verticalGradient( + colors = backLineColor, + endY = endGradientColorPosition, + ).toDynamicShader(), + backgroundShaderSecond = Brush.verticalGradient( + colors = backLineColorRight, + endY = endGradientColorPosition, + ).toDynamicShader(), + xSplitFraction = fractionValue, + thickness = 1.dp, + ), ), axisValueOverrider = axisValueOverrider, ) @@ -250,25 +160,26 @@ private fun LayerChartPreview( CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f), secondColorOnTheRightSide = true, axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), + markerFraction = 0.35f, canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, @@ -278,16 +189,16 @@ private fun LayerChartPreview( axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, @@ -297,29 +208,9 @@ private fun LayerChartPreview( axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - model = model, - ) - - CartesianChartHost( - modifier = Modifier.fillMaxWidth(), - chart = rememberCartesianChart( - rememberRawMarketChartLayer( - lineColor = lineColor, - backgroundLineColor = lineColor.copy(alpha = 0.24f), - secondLineColor = lineColor, - backgroundSecondLineColor = lineColor.copy(alpha = 0.24f), - markerFraction = 0.35f, - secondColorOnTheRightSide = true, - animationFraction = 0.7f, - axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), - canvasHeight = 495, - ), - ), - zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) } diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt new file mode 100644 index 0000000000..034af69926 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt @@ -0,0 +1,56 @@ +package com.tangem.common.ui.charts.layer + +import com.patrykandpatrick.vico.core.cartesian.CartesianDrawContext +import com.patrykandpatrick.vico.core.cartesian.CartesianMeasureContext +import com.patrykandpatrick.vico.core.cartesian.HorizontalDimensions +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.data.ChartValues + +@Suppress("MagicNumber") +class TimeItemPlacer : HorizontalAxis.ItemPlacer { + + private val ChartValues.measuredLabelValues + get() = buildList { + // produce exactly 6 values distributed evenly + val xLength = maxX - minX + val xStep = xLength / 7 + + repeat(times = 6) { + add(minX + xStep * (it + 1)) + } + } + + override fun getEndHorizontalAxisInset( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + tickThickness: Float, + maxLabelWidth: Float, + ): Float = 0f + + override fun getStartHorizontalAxisInset( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + tickThickness: Float, + maxLabelWidth: Float, + ): Float = 0f + + override fun getHeightMeasurementLabelValues( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + fullXRange: ClosedFloatingPointRange, + maxLabelWidth: Float, + ): List = context.chartValues.measuredLabelValues + + override fun getLabelValues( + context: CartesianDrawContext, + visibleXRange: ClosedFloatingPointRange, + fullXRange: ClosedFloatingPointRange, + maxLabelWidth: Float, + ): List = context.chartValues.measuredLabelValues + + override fun getWidthMeasurementLabelValues( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + fullXRange: ClosedFloatingPointRange, + ): List = context.chartValues.measuredLabelValues +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt deleted file mode 100644 index e27ce63a70..0000000000 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.tangem.common.ui.charts.marker - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost -import com.patrykandpatrick.vico.compose.cartesian.fullWidth -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart -import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState -import com.patrykandpatrick.vico.compose.common.component.rememberLayeredComponent -import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent -import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent -import com.patrykandpatrick.vico.compose.common.of -import com.patrykandpatrick.vico.compose.common.shader.color -import com.patrykandpatrick.vico.compose.common.shape.dashed -import com.patrykandpatrick.vico.core.cartesian.* -import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider -import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel -import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker -import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker -import com.patrykandpatrick.vico.core.common.Dimensions -import com.patrykandpatrick.vico.core.common.component.TextComponent -import com.patrykandpatrick.vico.core.common.shader.DynamicShader -import com.patrykandpatrick.vico.core.common.shape.Shape -import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import java.math.BigDecimal - -/** - * @param color The color of the indicator and guideline. - * @param innerCircleColor The color of the inner circle of the indicator. - * - * @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect. - */ -@Composable -internal fun rememberTangemChartMarker(color: Color, innerCircleColor: Color): CartesianMarker { - val indicatorFrontComponent = rememberShapeComponent( - shape = Shape.Pill, - color = innerCircleColor, - ) - val indicatorCenterComponent = rememberShapeComponent( - shape = Shape.Pill, - color = color, - ) - val indicatorRearComponent = rememberShapeComponent( - shape = Shape.Pill, - color = if (color == Color.Transparent) { - Color.Transparent - } else { - color.copy(alpha = INDICATOR_REAR_COLOR_ALPHA) - }, - ) - val indicator = rememberLayeredComponent( - rear = indicatorRearComponent, - front = rememberLayeredComponent( - rear = indicatorCenterComponent, - front = indicatorFrontComponent, - padding = indicatorPadding, - ), - padding = indicatorPadding, - ) - val guideline = rememberUnboundedLineComponent( - color = color, - verticalAddDrawSpace = TangemTheme.dimens.spacing24, - shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) }, - ) - return remember(indicator, guideline) { - object : DefaultCartesianMarker( - label = TextComponent.build { textSizeSp = 0f }, - indicator = indicator, - indicatorSizeDp = INDICATOR_SIZE_DP, - guideline = guideline, - ) { - override fun getInsets( - context: CartesianMeasureContext, - outInsets: Insets, - horizontalDimensions: HorizontalDimensions, - ) { - with(context) { - super.getInsets(context, outInsets, horizontalDimensions) - val baseShadowInsetDp = - CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP - outInsets.top += (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels - outInsets.bottom += (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels - } - } - } - } -} - -private val indicatorPadding = Dimensions.of(3.dp) -private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f -private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f -private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f -private const val INDICATOR_SIZE_DP = 16f -private const val INDICATOR_REAR_COLOR_ALPHA = .24f - -// region Preview - -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemChartMarkerPreview( - @PreviewParameter(MarketChartPreviewDataProvider::class) previewData: Pair, List>, -) { - val marker = rememberTangemChartMarker(Color.Red, Color.White) - val y = previewData.second.map { it.toFloat() } - val x = List(y.size) { it.toFloat() } - val model = CartesianChartModel(LineCartesianLayerModel.build { series(x, y) }) - - val centerAprx = (model.models[0].minX + model.models[0].maxX) / 2f - val center = model.models[0].getXDeltaGcd().let { centerAprx - centerAprx % it } - - TangemThemePreview { - Box(modifier = Modifier.background(TangemTheme.colors.background.primary)) { - CartesianChartHost( - modifier = Modifier.fillMaxWidth(), - chart = rememberCartesianChart( - rememberLineCartesianLayer( - listOf(rememberLineSpec(shader = DynamicShader.color(Color.Blue))), - axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), - ), - persistentMarkers = mapOf(center to marker), - ), - model = model, - marker = marker, - zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - ) - } - } -} - -// endregion Preview \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt index 7e4887fedc..44f973dc73 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt @@ -1,6 +1,8 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal @Immutable @@ -30,7 +32,7 @@ sealed interface MarketChartData { */ @Immutable data class Data( - val x: List = listOf(), - val y: List = listOf(), + val x: ImmutableList = persistentListOf(), + val y: ImmutableList = persistentListOf(), ) : MarketChartData } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt index bcad7d3ceb..86f5ef5dd6 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt @@ -3,13 +3,13 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Stable import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.common.data.ExtraStore -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import com.tangem.common.ui.charts.state.converter.PointValuesConverter +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.formatter.FormatterWrapWithCache +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.withContext -import java.math.BigDecimal +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * This class represents a transaction for updating the state and look of a Market Chart. @@ -25,7 +25,12 @@ class Transaction( var chartData: MarketChartData.NoData? = null fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) { - chartLook = block(currentLook) + val newLook = block(currentLook) + + chartLook = newLook.copy( + xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter), + yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter), + ) } fun updateState(block: (prev: MarketChartData) -> MarketChartData.NoData) { @@ -56,7 +61,12 @@ class TransactionSuspend( } fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) { - chartLook = block(currentLook) + val newLook = block(currentLook) + + chartLook = newLook.copy( + xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter), + yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter), + ) } internal fun updateState(block: (prev: MarketChartData) -> MarketChartData) { @@ -75,21 +85,24 @@ class TransactionSuspend( class MarketChartDataProducer private constructor( initialData: MarketChartData, initialLook: MarketChartLook, - val pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + val pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), private val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - internal val startDrawingAnimation = MutableSharedFlow() internal val dataState = MutableStateFlow(initialData) internal val lookState = MutableStateFlow(initialLook) internal val entries = MutableStateFlow>(emptyList()) - - internal val modelProducer = CartesianChartModelProducer.build(dispatcher = dispatcher) + internal val modelProducer = CartesianChartModelProducer(dispatcher = dispatcher) + internal val rawData = MutableStateFlow(null) + private val mutex = Mutex() /** * This function runs a suspending transaction block to update the state and look of the Market Chart. */ - suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = - handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block)) + suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = withContext(dispatcher) { + mutex.withLock { + handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block)) + } + } /** * This function runs a non-suspending transaction block to update the state and look of the Market Chart. @@ -102,32 +115,30 @@ class MarketChartDataProducer private constructor( val chartData = transaction.chartData val oldData = dataState.value - if (chartData != null) { - dataState.value = chartData - } - if (chartData is MarketChartData.Data && (oldData !is MarketChartData.Data || oldData != chartData)) { - if (lookState.value.animationOnDataChange) { - startDrawingAnimation.emit(Unit) - } - withContext(dispatcher) { - val rawData = pointsValuesConverter.convert(chartData) + (lookState.value.xAxisFormatter as? FormatterWrapWithCache)?.clearCache() + (lookState.value.yAxisFormatter as? FormatterWrapWithCache)?.clearCache() - val entriesLocal = - rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) } + val rawData = pointsValuesConverter.convert(chartData) - entries.value = entriesLocal + val entriesLocal = + rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) } + currentCoroutineContext().ensureActive() + + runCatching { modelProducer.runTransaction { add(LineCartesianLayerModel.Partial(series = listOf(entriesLocal))) - - updateExtras { - it[entriesKey] = entriesLocal - it[xKey] = chartData.x - it[yKey] = chartData.y - } - }.await() + } } + + entries.value = entriesLocal + dataState.value = chartData + this.rawData.value = rawData + + delay(timeMillis = 200) + } else if (chartData != null) { + dataState.value = chartData } nonSuspendTransaction?.let { handleTransaction(it) } @@ -143,10 +154,6 @@ class MarketChartDataProducer private constructor( } companion object { - internal val entriesKey = ExtraStore.Key>() - internal val xKey = ExtraStore.Key>() - internal val yKey = ExtraStore.Key>() - private val initialData: MarketChartData = MarketChartData.NoData.Empty private val initialLook: MarketChartLook = MarketChartLook() @@ -159,7 +166,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ suspend fun buildSuspend( - pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), dispatcher: CoroutineDispatcher = Dispatchers.Default, block: TransactionSuspend.() -> Unit, ): MarketChartDataProducer { @@ -184,7 +191,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ fun build( - pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), dispatcher: CoroutineDispatcher = Dispatchers.Default, block: Transaction.() -> Unit, ): MarketChartDataProducer { diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt index c9ecaae2c0..72c608da53 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt @@ -1,5 +1,8 @@ package com.tangem.common.ui.charts.state +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.formatter.AxisLabelFormatter + /** * This class represents the look and feel of a Market Chart. * It includes properties for type, marker highlight, animation on data change, animate data appearance, @@ -7,16 +10,13 @@ package com.tangem.common.ui.charts.state * * @property type The type of the chart, can be either Growing or Falling. * @property markerHighlightRightSide A boolean indicating whether the marker highlights the right side of the chart. - * @property animationOnDataChange A boolean indicating whether to animate on data change. - * @property animateDataAppearance A boolean indicating whether to animate data appearance. * @property xAxisFormatter A formatter for the x-axis labels. * @property yAxisFormatter A formatter for the y-axis labels. */ +@Immutable data class MarketChartLook( val type: Type = Type.Growing, val markerHighlightRightSide: Boolean = true, - val animationOnDataChange: Boolean = false, - val animateDataAppearance: Boolean = false, val xAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() }, val yAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() }, ) { @@ -24,5 +24,6 @@ data class MarketChartLook( enum class Type { Growing, Falling, + Neutral, } } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt index a1b7e91f2c..df6d124201 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt @@ -1,9 +1,20 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +/** + * This class represents raw data for a Market Chart. Used for drawing the chart. + * + * @property originalIndexes If the source data has the original representation (due to reduced sampling), + * this list contains the original indexes of the data points. + * @property y The list of y-values. + * @property x The list of x-values. + */ @Immutable data class MarketChartRawData( - val y: List, - val x: List = List(y.size) { 1f }, + val originalIndexes: ImmutableList? = null, + val y: ImmutableList, + val x: ImmutableList = List(y.size) { 1.0 }.toImmutableList(), ) \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt index 92a0379f5d..e031fc1a27 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt @@ -2,7 +2,6 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener @@ -20,26 +19,23 @@ import java.math.BigDecimal @Composable fun rememberMarketChartState( dataProducer: MarketChartDataProducer = remember { MarketChartDataProducer.build {} }, - colorMapper: (MarketChartLook.Type) -> Color = { - when (it) { - MarketChartLook.Type.Growing -> Color.Green - MarketChartLook.Type.Falling -> Color.Red + colorMapper: (MarketChartLook.Type) -> Color = remember { + { + when (it) { + MarketChartLook.Type.Growing -> Color.Green + MarketChartLook.Type.Falling -> Color.Red + MarketChartLook.Type.Neutral -> Color.Gray + } } }, onMarkerShown: (x: BigDecimal?, y: BigDecimal?) -> Unit = { _, _ -> }, ): MarketChartState { - val lookState = dataProducer.lookState.collectAsStateWithLifecycle() + val lookState = dataProducer.lookState.collectAsState() val state = remember(dataProducer, lookState, colorMapper, onMarkerShown) { MarketChartState(dataProducer, lookState, colorMapper, onMarkerShown) } - LaunchedEffect(Unit) { - dataProducer.startDrawingAnimation.collect { - state.startDrawingAnimation() - } - } - return state } @@ -59,7 +55,6 @@ class MarketChartState internal constructor( private val colorMapper: (MarketChartLook.Type) -> Color, private val markerCallback: (x: BigDecimal?, y: BigDecimal?) -> Unit, ) { - internal val startDrawingAnimationState = mutableStateOf(false) internal val modelProducer = dataProducer.modelProducer internal val chartColor by derivedStateOf { @@ -70,29 +65,29 @@ class MarketChartState internal constructor( lookState.value.markerHighlightRightSide } - internal val xValueFormatter by derivedStateOf { - CartesianValueFormatter { value, _, _ -> - val state = dataProducer.dataState.value as? MarketChartData.Data - ?: return@CartesianValueFormatter value.toString() + internal val xValueFormatter = CartesianValueFormatter { value, _, _ -> + val formatter = dataProducer.lookState.value.xAxisFormatter - lookState.value.xAxisFormatter.format( - value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state), - ) - } + val state = dataProducer.dataState.value as? MarketChartData.Data + ?: return@CartesianValueFormatter value.toString() + + formatter.format( + value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state), + ) } - internal val yValueFormatter by derivedStateOf { - CartesianValueFormatter { value, _, _ -> - val state = dataProducer.dataState.value as? MarketChartData.Data - ?: return@CartesianValueFormatter value.toString() + internal val yValueFormatter = CartesianValueFormatter { value, _, _ -> + val formatter = dataProducer.lookState.value.yAxisFormatter - lookState.value.yAxisFormatter.format( - value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state), - ) - } + val state = dataProducer.dataState.value as? MarketChartData.Data + ?: return@CartesianValueFormatter value.toString() + + formatter.format( + value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state), + ) } - internal var markerFraction: Float? by mutableStateOf(null) + internal var markerFraction by mutableStateOf(null) internal val markerVisibilityListener = object : CartesianMarkerVisibilityListener { override fun onShown(marker: CartesianMarker, targets: List) { @@ -116,24 +111,17 @@ class MarketChartState internal constructor( } } - val isDrawingAnimationInProgress: Boolean by derivedStateOf { - startDrawingAnimationState.value - } - private fun getPoint(targets: List): Pair? { val entry = (targets[0] as LineCartesianLayerMarkerTarget).points[0].entry val entryIndex = dataProducer.entries.value.indexOf(entry).takeIf { it != -1 } ?: return null val state = dataProducer.dataState.value as? MarketChartData.Data ?: return null - val x = state.x.getOrNull(entryIndex) ?: return null - val y = state.y.getOrNull(entryIndex) ?: return null + val rawData = dataProducer.rawData.value ?: return null + + val originalIndex = rawData.originalIndexes?.getOrNull(entryIndex) + val index = originalIndex ?: entryIndex + + val x = state.x.getOrNull(index) ?: return null + val y = state.y.getOrNull(index) ?: return null return x to y } - - fun startDrawingAnimation() { - startDrawingAnimationState.value = true - } - - fun stopDrawingAnimation() { - startDrawingAnimationState.value = false - } } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt deleted file mode 100644 index 1dab1cd0cc..0000000000 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.tangem.common.ui.charts.state - -import java.math.BigDecimal - -/** - * Interface to convert chart data values to Floats and backwards. - * - * We need to convert the values on the graph to floating point values in order to display them correctly on the canvas. - * We also need to determine exactly which floating point value on the graph corresponds to the decimal point, - * so that we can format the actual value and display on the x/y axis. - */ -interface PointValuesConverter { - - fun convert(data: MarketChartData.Data): MarketChartRawData - - fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal - - fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal -} - -object DefaultPointValuesConverter : PointValuesConverter { - - override fun convert(data: MarketChartData.Data): MarketChartRawData { - val minX = data.x.min() - val minY = data.y.min() - - val normY = data.y.map { normalize(it, minY) } - val normX = data.x.map { normalize(it, minX) } - - return MarketChartRawData( - x = normX, - y = normY, - ) - } - - override fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal { - val dataMin = data.x.min() - val scale = dataMin.scale() - val bVal = if (scale > 2) { - rawX.toBigDecimal().movePointLeft(scale - 2) + dataMin - } else { - rawX.toBigDecimal() + dataMin - } - - return bVal - } - - override fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal { - val dataMin = data.y.min() - val scale = dataMin.scale() - val bVal = if (scale > 2) { - rawY.toBigDecimal().movePointLeft(scale - 2) + dataMin - } else { - rawY.toBigDecimal() + dataMin - } - - return bVal - } - - // TODO enhance algorithm for values with big difference between min and max, which cannot fit in Float - private fun normalize(value: BigDecimal, min: BigDecimal, scale: Int = min.scale()): Float { - val n = value - min - return if (scale > 2) { - n.movePointRight(scale - 2).toFloat() - } else { - n.toFloat() - } - } -} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt new file mode 100644 index 0000000000..107eef4c68 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt @@ -0,0 +1,23 @@ +package com.tangem.common.ui.charts.state.converter + +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import java.math.BigDecimal + +/** + * Interface to convert chart data values to Floats and backwards. + * + * We need to convert the values on the graph to floating point values in order to display them correctly on the canvas. + * We also need to determine exactly which floating point value on the graph corresponds to the decimal point, + * so that we can format the actual value and display on the x/y axis. + * + * **[prepareRawXForFormat] and [prepareRawYForFormat] must be very fast because they are called in the onDraw method** + */ +interface PointValuesConverter { + + fun convert(data: MarketChartData.Data): MarketChartRawData + + fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal + + fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt new file mode 100644 index 0000000000..e51a8f72a6 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt @@ -0,0 +1,108 @@ +package com.tangem.common.ui.charts.state.converter + +import com.tangem.common.ui.charts.downsample.LTThreeBuckets +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +@Suppress("MagicNumber") +class PriceAndTimePointValuesConverter( + private val needToFormatAxis: Boolean, +) : PointValuesConverter { + + private data class MinMaxCache( + val minX: BigDecimal, + val maxX: BigDecimal, + val minY: BigDecimal, + val maxY: BigDecimal, + ) + + private var minMaxCache = MinMaxCache(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO) + private val formatYValuesCache = mutableMapOf() + private val formatXValuesCache = mutableMapOf() + + override fun convert(data: MarketChartData.Data): MarketChartRawData { + formatYValuesCache.clear() + formatXValuesCache.clear() + val cache = MinMaxCache( + minY = data.y.minOrNull() ?: BigDecimal.ZERO, + maxY = data.y.maxOrNull() ?: BigDecimal.ZERO, + minX = data.x.minOrNull() ?: BigDecimal.ZERO, + maxX = data.x.maxOrNull() ?: BigDecimal.ZERO, + ) + minMaxCache = cache + + val normY = data.y.normalizeToDouble(min = cache.minY, max = cache.maxY) + val normX = data.x.normalizeTime(min = cache.minX, max = cache.maxX) + + return if (normX.size > MAX_POINTS) { + LTThreeBuckets + .downsample(normX, normY, MAX_POINTS - 2) + .let { + MarketChartRawData( + originalIndexes = it.originalIndexes.toImmutableList(), + x = it.x.toImmutableList(), + y = it.y.toImmutableList(), + ) + } + } else { + MarketChartRawData( + x = normX.toImmutableList(), + y = normY.toImmutableList(), + ) + } + } + + override fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal { + if (!needToFormatAxis) return BigDecimal.ZERO + if (formatXValuesCache.containsKey(rawX)) return formatXValuesCache[rawX]!! + + val result = (rawX * MINUTE).toBigDecimal() + + formatXValuesCache[rawX] = result + return result + } + + override fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal { + if (!needToFormatAxis) return BigDecimal.ZERO + if (formatYValuesCache.containsKey(rawY)) return formatYValuesCache[rawY]!! + + val min = minMaxCache.minY + val max = minMaxCache.maxY + val length = max - min + + val result = when { + rawY < 0.01f -> min + rawY < 0.55f && rawY > 0.45f -> min + length / 2.toBigDecimal() + rawY > 0.97f && rawY < 1.01f -> max + else -> length * rawY.toBigDecimal() + min + } + formatYValuesCache[rawY] = result + return result + } + + private fun List.normalizeToDouble(min: BigDecimal, max: BigDecimal): List { + if (min == max) { + return List(size) { 0.5 } + } + + return map { ((it - min) / (max - min)).toDouble() } + } + + private fun List.normalizeTime(min: BigDecimal, max: BigDecimal): List { + if (min == max) { + return List(size) { 0.5 } + } + + return map { + (it / MINUTE_BIG).toDouble() + } + } + + private companion object { + private const val MAX_POINTS = 502 + private const val MINUTE = 60000L + private val MINUTE_BIG = 60000L.toBigDecimal() + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt similarity index 76% rename from common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt rename to common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt index e01681994c..46feae32c2 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt @@ -1,4 +1,4 @@ -package com.tangem.common.ui.charts.state +package com.tangem.common.ui.charts.state.formatter import androidx.compose.runtime.Stable import java.math.BigDecimal @@ -7,6 +7,8 @@ import java.math.BigDecimal * Used for formatting the axis labels in a chart. * It takes a BigDecimal value and returns a CharSequence that represents the formatted label. * + * [format] has to be very fast because it is called in the onDraw method. + * * @param value The value to be formatted. * @return The formatted label as a CharSequence. */ diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt new file mode 100644 index 0000000000..b0a0fbe8bc --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt @@ -0,0 +1,15 @@ +package com.tangem.common.ui.charts.state.formatter + +import java.math.BigDecimal + +internal class FormatterWrapWithCache(private val formatter: AxisLabelFormatter) : AxisLabelFormatter { + private val cache = mutableMapOf() + + override fun format(value: BigDecimal): CharSequence { + return cache.getOrPut(value) { formatter.format(value) } + } + + fun clearCache() { + cache.clear() + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt index 78ce58f37d..9597b3d764 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt @@ -2,10 +2,12 @@ package com.tangem.common.ui.amountScreen.converters import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.checkExceedBalance import com.tangem.common.ui.amountScreen.utils.getFiatValue import com.tangem.common.ui.amountScreen.utils.getKeyboardAction +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.utils.isNullOrZero @@ -51,6 +53,7 @@ class AmountReduceByTransformer( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, + error = resourceReference(R.string.send_validation_amount_exceeds_balance), cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), keyboardOptions = KeyboardOptions( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt index aed8332b0b..c81634b818 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceToTransformer.kt @@ -2,10 +2,12 @@ package com.tangem.common.ui.amountScreen.converters import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.checkExceedBalance import com.tangem.common.ui.amountScreen.utils.getFiatValue import com.tangem.common.ui.amountScreen.utils.getKeyboardAction +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.utils.isNullOrZero @@ -45,6 +47,7 @@ class AmountReduceToTransformer( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, + error = resourceReference(R.string.send_validation_amount_exceeds_balance), cryptoAmount = amountTextField.cryptoAmount.copy(value = value), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), keyboardOptions = KeyboardOptions( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index ad251378f6..410a23b468 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -54,7 +54,7 @@ class AmountStateConverter( return AmountState.Data( walletName = userWallet.name, - walletBalance = resourceReference(R.string.send_wallet_balance_format, wrappedList(crypto, fiat)), + walletBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)), tokenIconState = iconStateConverter.convert(status), amountTextField = amountFieldConverter.convert(value), isPrimaryButtonEnabled = false, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt index f0be223891..d45e7c07bd 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt @@ -2,11 +2,13 @@ package com.tangem.common.ui.amountScreen.converters.field import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType +import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.checkExceedBalance import com.tangem.common.ui.amountScreen.utils.getCryptoValue import com.tangem.common.ui.amountScreen.utils.getFiatValue import com.tangem.common.ui.amountScreen.utils.getKeyboardAction +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.utils.isNullOrZero @@ -59,6 +61,7 @@ class AmountFieldChangeTransformer( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, + error = resourceReference(R.string.send_validation_amount_exceeds_balance), cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), keyboardOptions = KeyboardOptions( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt index 1144bf4bcd..f190cea1db 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt @@ -1,16 +1,16 @@ package com.tangem.common.ui.amountScreen.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.BottomCenter +import androidx.compose.ui.Alignment.Companion.TopCenter import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -80,6 +80,8 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri val secondaryAmount = if (amountField.isFiatValue) amountField.cryptoAmount else amountField.fiatAmount Box( modifier = Modifier + .fillMaxWidth() + .animateContentSize() .padding( top = TangemTheme.dimens.spacing8, start = TangemTheme.dimens.spacing12, @@ -105,7 +107,7 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri color = TangemTheme.colors.text.tertiary, textAlign = TextAlign.Center, modifier = Modifier - .align(BottomCenter) + .align(TopCenter) .padding(bottom = TangemTheme.dimens.spacing32), ) AmountFieldError( @@ -113,7 +115,10 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri error = amountField.error, modifier = Modifier .align(BottomCenter) - .padding(bottom = TangemTheme.dimens.spacing12), + .padding( + top = TangemTheme.dimens.spacing20, + bottom = TangemTheme.dimens.spacing12, + ), ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt index 67c6ed8cc9..c578193df0 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/utils/FormatterUtils.kt @@ -1,30 +1,12 @@ package com.tangem.common.ui.amountScreen.utils -import com.tangem.blockchain.common.Amount import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.BigDecimalFormatter.EMPTY_BALANCE_SIGN import com.tangem.domain.appcurrency.model.AppCurrency import java.math.BigDecimal -private const val CRYPTO_FEE_DECIMALS = 6 - -fun getCryptoReference(amount: Amount?, isFeeApproximate: Boolean): TextReference? { - if (amount == null) return null - return combinedReference( - if (isFeeApproximate) stringReference("${BigDecimalFormatter.CAN_BE_LOWER_SIGN} ") else TextReference.EMPTY, - stringReference( - BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = amount.value, - cryptoCurrency = amount.currencySymbol, - decimals = amount.decimals.coerceAtMost(CRYPTO_FEE_DECIMALS), - ), - ), - ) -} - fun getFiatReference(value: BigDecimal?, rate: BigDecimal?, appCurrency: AppCurrency): TextReference? { if (value == null || rate == null) return null val formattedFiat = getFiatString(value = value, rate = rate, appCurrency = appCurrency) diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt index ba65ad6d9c..6f61b650b4 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt @@ -93,10 +93,12 @@ private fun GiveTxPermissionBottomSheetContent(content: GiveTxPermissionBottomSh PrimaryButtonIconEnd( text = stringResource(id = R.string.common_approve), iconResId = R.drawable.ic_tangem_24, + showProgress = data.approveButton.loading, modifier = Modifier .fillMaxWidth() .padding(horizontal = TangemTheme.dimens.spacing16), onClick = data.approveButton.onClick, + enabled = data.approveButton.enabled, ) SpacerH12() @@ -107,6 +109,7 @@ private fun GiveTxPermissionBottomSheetContent(content: GiveTxPermissionBottomSh .fillMaxWidth() .padding(horizontal = TangemTheme.dimens.spacing16), onClick = content.onCancel, + enabled = data.cancelButton.enabled, ) SpacerH16() @@ -151,7 +154,7 @@ private fun AmountItem( currency: String, approveType: ApproveType, approveItems: ImmutableList, - onChangeApproveType: (ApproveType) -> Unit, + onChangeApproveType: ((ApproveType) -> Unit)?, ) { var isExpandSelector by remember { mutableStateOf(false) } var amountSize by remember { mutableStateOf(IntSize.Zero) } @@ -208,8 +211,10 @@ private fun AmountItem( isExpanded = isExpandSelector, onDismiss = { isExpandSelector = false }, onItemClick = { approveType -> - isExpandSelector = false - onChangeApproveType.invoke(approveType) + onChangeApproveType?.let { + isExpandSelector = false + onChangeApproveType.invoke(approveType) + } }, items = approveItems, selectedType = approveType, diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt index dbdce22ba9..908e42559b 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/state/GiveTxPermissionState.kt @@ -22,7 +22,7 @@ sealed class GiveTxPermissionState { val approveItems: ImmutableList = ApproveType.entries.toImmutableList(), val approveButton: ApprovePermissionButton, val cancelButton: CancelPermissionButton, - val onChangeApproveType: (ApproveType) -> Unit, + val onChangeApproveType: ((ApproveType) -> Unit)? = null, ) : GiveTxPermissionState() } diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt new file mode 100644 index 0000000000..8a9ccffd1e --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsBlock.kt @@ -0,0 +1,202 @@ +package com.tangem.common.ui.navigationButtons + +import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.navigationButtons.preview.NavigationButtonsPreview +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.rememberHapticFeedback +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun NavigationButtonsBlock(buttonState: NavigationButtonsState, modifier: Modifier = Modifier) { + val state = buttonState as? NavigationButtonsState.Data + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxWidth(), + ) { + ExtraButtons(state?.extraButtons, state?.txUrl) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + PreviousButton(state?.prevButton) + PrimaryButton(state?.primaryButton, modifier = Modifier.weight(1f)) + } + + SecondaryButton(state?.secondaryButton) + } +} + +@Composable +private fun PrimaryButton(primaryButton: NavigationButton?, modifier: Modifier = Modifier) { + AnimatedContent( + targetState = primaryButton, + transitionSpec = { + val isPrimaryToHide = targetState != null && initialState == null + val isPrimaryWasVisible = targetState == null && initialState != null + if (isPrimaryToHide || isPrimaryWasVisible) { + slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()) + .togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut())) + } else { + fadeIn().togetherWith(fadeOut()) + } + }, + contentAlignment = Alignment.Center, + label = "Animate show primary button", + modifier = modifier.fillMaxWidth(), + ) { button -> + if (button != null && button.textReference != TextReference.EMPTY) { + val icon = if (button.iconRes != null && button.isIconVisible) { + TangemButtonIconPosition.End(iconResId = button.iconRes) + } else { + TangemButtonIconPosition.None + } + TangemButton( + text = button.textReference.resolveReference(), + enabled = button.isEnabled, + onClick = button.onClick, + showProgress = button.showProgress, + colors = TangemButtonsDefaults.primaryButtonColors, + icon = icon, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Spacer(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun SecondaryButton(secondaryButton: NavigationButton?) { + AnimatedContent( + targetState = secondaryButton, + transitionSpec = { + val isPrimaryToHide = targetState != null && initialState == null + val isPrimaryWasVisible = targetState == null && initialState != null + if (isPrimaryToHide || isPrimaryWasVisible) { + slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()) + .togetherWith(slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut())) + } else { + fadeIn().togetherWith(fadeOut()) + } + }, + contentAlignment = Alignment.Center, + label = "Animate show secondary button", + modifier = Modifier.fillMaxWidth(), + ) { button -> + if (button != null && button.textReference != TextReference.EMPTY) { + val icon = button.iconRes?.let { TangemButtonIconPosition.End(iconResId = it) } + ?: TangemButtonIconPosition.None + + TangemButton( + text = button.textReference.resolveReference(), + enabled = button.isEnabled, + onClick = button.onClick, + icon = icon, + showProgress = button.showProgress, + colors = TangemButtonsDefaults.secondaryButtonColors, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + ) + } else { + Spacer(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun PreviousButton(prevButton: NavigationButton?) { + AnimatedVisibility( + visible = prevButton != null, + enter = expandHorizontally(expandFrom = Alignment.End), + exit = shrinkHorizontally(shrinkTowards = Alignment.End), + label = "Animate show prev button", + ) { + val button = remember(this) { requireNotNull(prevButton) } + if (button.iconRes != null && button.isIconVisible) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(button.iconRes), + ), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + modifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius16)) + .background(TangemTheme.colors.button.secondary) + .clickable(onClick = button.onClick) + .padding(TangemTheme.dimens.spacing12), + ) + } + } +} + +@Composable +private fun ExtraButtons(extraButtons: ImmutableList?, txUrl: String?) { + AnimatedVisibility( + visible = !txUrl.isNullOrBlank() && extraButtons != null, + enter = slideInVertically(initialOffsetY = { it / 2 }).plus(fadeIn()), + exit = slideOutVertically(targetOffsetY = { it / 2 }).plus(fadeOut()), + label = "Animate show sent state buttons", + modifier = Modifier.fillMaxWidth(), + ) { + val buttons = remember(this) { requireNotNull(extraButtons) } + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + ) { + buttons.forEach { button -> + val icon = button.iconRes?.let { TangemButtonIconPosition.Start(iconResId = it) } + ?: TangemButtonIconPosition.None + TangemButton( + text = button.textReference.resolveReference(), + icon = icon, + onClick = rememberHapticFeedback(state = button, onAction = button.onClick), + modifier = Modifier.weight(1f), + enabled = button.isEnabled, + showProgress = false, + colors = TangemButtonsDefaults.secondaryButtonColors, + ) + } + } + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NavigationButtonsBlock_Preview( + @PreviewParameter(NavigationButtonsBlockDataProvider::class) navigationButtonsState: NavigationButtonsState, +) { + TangemThemePreview { + NavigationButtonsBlock(navigationButtonsState) + } +} + +private class NavigationButtonsBlockDataProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(NavigationButtonsPreview.allButtons) +} + +// endregion \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt new file mode 100644 index 0000000000..c125a871d1 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt @@ -0,0 +1,27 @@ +package com.tangem.common.ui.navigationButtons + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +sealed class NavigationButtonsState { + data object Empty : NavigationButtonsState() + + data class Data( + val primaryButton: NavigationButton, + val prevButton: NavigationButton?, + val secondaryButton: NavigationButton?, + val extraButtons: ImmutableList, + val txUrl: String? = null, + ) : NavigationButtonsState() +} + +data class NavigationButton( + val textReference: TextReference, + @DrawableRes val iconRes: Int? = null, + val isSecondary: Boolean, + val isIconVisible: Boolean, + val showProgress: Boolean, + val isEnabled: Boolean, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt new file mode 100644 index 0000000000..738598de82 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/preview/NavigationButtonsPreview.kt @@ -0,0 +1,67 @@ +package com.tangem.common.ui.navigationButtons.preview + +import com.tangem.common.ui.R +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import kotlinx.collections.immutable.persistentListOf + +internal object NavigationButtonsPreview { + + private val extraButtons = persistentListOf( + NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_tangem_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = {}, + ), + NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_tangem_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = {}, + ), + ) + + private val next = NavigationButton( + textReference = resourceReference(R.string.common_next), + isSecondary = false, + isIconVisible = false, + showProgress = false, + isEnabled = true, + onClick = {}, + ) + private val prev = NavigationButton( + textReference = TextReference.EMPTY, + iconRes = R.drawable.ic_back_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = {}, + ) + + private val finished = NavigationButton( + textReference = resourceReference(R.string.common_close), + isSecondary = false, + isIconVisible = false, + showProgress = false, + isEnabled = true, + onClick = {}, + ) + + val allButtons = NavigationButtonsState.Data( + primaryButton = finished, + prevButton = prev, + secondaryButton = next, + extraButtons = extraButtons, + txUrl = "https://tangem.com", + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchEnvironmentInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchEnvironmentInterceptor.kt new file mode 100644 index 0000000000..dbfbc5e54f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/SwitchEnvironmentInterceptor.kt @@ -0,0 +1,54 @@ +package com.tangem.datasource.api.common + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.utils.Provider +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okio.IOException + +/** + * Switch api environment [Interceptor] + * + * @property id api config id [ApiConfig.ID] + * @property apiConfigsManager api configs manager + * +[REDACTED_AUTHOR] + */ +internal class SwitchEnvironmentInterceptor( + private val id: ApiConfig.ID, + private val apiConfigsManager: ApiConfigsManager, +) : Interceptor { + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + val builder = request.newBuilder() + + val environmentConfig = apiConfigsManager.getEnvironmentConfig(id) + + request = builder + .url(url = request.url.adjustBaseUrl(environmentConfig.baseUrl)) + .addHeaders(headers = environmentConfig.headers) + .build() + + return chain.proceed(request) + } + + private fun HttpUrl.adjustBaseUrl(url: String): HttpUrl { + return this.newBuilder() + .host(host = url.toHttpUrl().host) + .build() + } + + private fun Request.Builder.addHeaders(headers: Map>): Request.Builder { + headers.forEach { (name, valueProvider) -> + addHeader(name = name, value = valueProvider()) + } + + return this + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt index 2e6adf8fe7..f5627c6f96 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/adapter/UnknownEnumMoshiAdapter.kt @@ -3,9 +3,11 @@ package com.tangem.datasource.api.common.adapter import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.adapters.EnumJsonAdapter -import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO.BalanceTypeDTO import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO -import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardClaimingDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.RewardTypeDTO import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO import com.tangem.datasource.api.stakekit.models.response.model.transaction.StakingTransactionStatusDTO @@ -24,13 +26,15 @@ object UnknownEnumMoshiAdapter { fun Moshi.Builder.addStakeKitEnumFallbackAdapters(): Moshi.Builder { val map = mapOf( + BalanceTypeDTO::class.java to BalanceTypeDTO.UNKNOWN, NetworkTypeDTO::class.java to NetworkTypeDTO.UNKNOWN, - StakingActionTypeDTO::class.java to StakingActionTypeDTO.UNKNOWN, - YieldDTO.RewardTypeDTO::class.java to YieldDTO.RewardTypeDTO.UNKNOWN, - BalanceDTO.BalanceType::class.java to BalanceDTO.BalanceType.UNKNOWN, - StakingTransactionTypeDTO::class.java to StakingTransactionTypeDTO.UNKNOWN, - StakingTransactionStatusDTO::class.java to StakingTransactionStatusDTO.UNKNOWN, + RewardClaimingDTO::class.java to RewardClaimingDTO.UNKNOWN, + RewardScheduleDTO::class.java to RewardScheduleDTO.UNKNOWN, + RewardTypeDTO::class.java to RewardTypeDTO.UNKNOWN, StakingActionStatusDTO::class.java to StakingActionStatusDTO.UNKNOWN, + StakingActionTypeDTO::class.java to StakingActionTypeDTO.UNKNOWN, + StakingTransactionStatusDTO::class.java to StakingTransactionStatusDTO.UNKNOWN, + StakingTransactionTypeDTO::class.java to StakingTransactionTypeDTO.UNKNOWN, ) return apply { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt new file mode 100644 index 0000000000..caa3117899 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -0,0 +1,42 @@ +package com.tangem.datasource.api.common.config + +typealias ApiConfigs = Set<@JvmSuppressWildcards ApiConfig> + +/** + * Api config + * + * @see API configuration + * +[REDACTED_AUTHOR] + */ +sealed class ApiConfig { + + /** Default environment */ + abstract val defaultEnvironment: ApiEnvironment + + /** Available environments */ + abstract val environmentConfigs: List + + /** Unique id */ + val id: ID = initializeId() + + enum class ID { + Express, + TangemTech, + } + + 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" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt new file mode 100644 index 0000000000..00945bf772 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironment.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.config + +/** + * Api environment + * +[REDACTED_AUTHOR] + */ +enum class ApiEnvironment { + DEV, STAGE, PROD +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironmentConfig.kt new file mode 100644 index 0000000000..4ab1342caf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiEnvironmentConfig.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.utils.Provider + +/** + * Api environment config + * + * @property environment environment + * @property baseUrl base url + * @property headers headers + * +[REDACTED_AUTHOR] + */ +data class ApiEnvironmentConfig( + val environment: ApiEnvironment, + val baseUrl: String, + val headers: Map> = emptyMap(), +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt new file mode 100644 index 0000000000..134d24a3ac --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -0,0 +1,81 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.BuildConfig +import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.utils.RequestHeader +import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.utils.Provider +import com.tangem.utils.version.AppVersionProvider + +/** + * Express [ApiConfig] + * + * @property configManager config manager + * @property expressAuthProvider express auth provider + * @property appVersionProvider app version provider + */ +internal class Express( + private val configManager: ConfigManager, + private val expressAuthProvider: ExpressAuthProvider, + private val appVersionProvider: AppVersionProvider, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() + + override val environmentConfigs: List = listOf( + createDevEnvironment(), + createStageEnvironment(), + createProdEnvironment(), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "[REDACTED_ENV_URL]", + headers = createHeaders(isProd = false), + ) + + private fun createStageEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.STAGE, + baseUrl = "[REDACTED_ENV_URL]", + headers = createHeaders(isProd = false), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://express.tangem.com/v1/", + headers = createHeaders(isProd = true), + ) + + private fun createHeaders(isProd: Boolean) = buildMap { + put(key = "api-key", value = Provider { getApiKey(isProd) }) + put(key = "user-id", value = Provider(expressAuthProvider::getUserId)) + put(key = "session-id", value = Provider(expressAuthProvider::getSessionId)) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider).values) + } + + private fun getApiKey(isProd: Boolean): String { + return if (isProd) { + configManager.config.express + } else { + configManager.config.devExpress + } + ?.apiKey + ?: error("No express config provided") + } + + private companion object { + + fun getInitialEnvironment(): ApiEnvironment { + return when (BuildConfig.BUILD_TYPE) { + DEBUG_BUILD_TYPE -> ApiEnvironment.DEV + INTERNAL_BUILD_TYPE, + MOCKED_BUILD_TYPE, + -> ApiEnvironment.STAGE + EXTERNAL_BUILD_TYPE, + RELEASE_BUILD_TYPE, + -> ApiEnvironment.PROD + else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") + } + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt new file mode 100644 index 0000000000..f34ffb3590 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.api.common.config + +/** TangemTech [ApiConfig] */ +internal class TangemTech : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD + + override val environmentConfigs = listOf( + createDevEnvironment(), + createProdEnvironment(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.tangem-tech.com/v1/", + headers = emptyMap(), // TODO: [REDACTED_JIRA] + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "https://devapi.tangem-tech.com/v1/", + headers = emptyMap(), // TODO: [REDACTED_JIRA] + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt new file mode 100644 index 0000000000..880a57f286 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironmentConfig + +/** + * Api configs manager + * +[REDACTED_AUTHOR] + */ +interface ApiConfigsManager { + + /** Initialize resources */ + suspend fun initialize() {} + + /** Get environment config by [id] */ + fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt new file mode 100644 index 0000000000..4f4efdd955 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt @@ -0,0 +1,70 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiConfigs +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.ApiEnvironmentConfig +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* + +/** + * Implementation of [ApiConfigsManager] in DEV environment + * + * @param apiConfigs api configs + * @property appPreferencesStore app preferences store + * @property dispatchers coroutine dispatcher provider + */ +internal class DevApiConfigsManager( + apiConfigs: ApiConfigs, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : MutableApiConfigsManager { + + override val configs: Flow> get() = _apiConfigs + + private val _apiConfigs = MutableStateFlow(value = apiConfigs.associateWith { it.defaultEnvironment }) + + override suspend fun initialize() { + // We can't use appPreferencesStore.getObjectMap as base flow, + // because we should keep possibility to work with configs synchronous. + // See [getBaseUrl] + appPreferencesStore.getObjectMap(PreferencesKeys.apiConfigsEnvironmentKey) + .onEach { savedEnvironments -> + _apiConfigs.update { apiConfigs -> + apiConfigs.mapValues { + val (config, currentEnvironment) = it + + savedEnvironments[config.id.name] ?: currentEnvironment + } + } + } + .launchIn(CoroutineScope(dispatchers.main)) + } + + override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig { + val apiConfigs = _apiConfigs.value + + val config = apiConfigs.map { it }.firstOrNull { it.key.id == id }?.key + ?: error("Api config with id [$id] not found") + + val currentEnvironment = apiConfigs[config] + ?: error("Current environment of api config with id [$id] not found") + + return config.environmentConfigs.firstOrNull { it.environment == currentEnvironment } + ?: error("Api config with id [$id] doesn't contain environment [$currentEnvironment]") + } + + override suspend fun changeEnvironment(id: String, environment: ApiEnvironment) { + appPreferencesStore.editData { mutablePreferences -> + val updatedMap = mutablePreferences.getObjectMap(PreferencesKeys.apiConfigsEnvironmentKey) + .toMutableMap() + .apply { put(id, environment) } + + mutablePreferences.setObjectMap(PreferencesKeys.apiConfigsEnvironmentKey, updatedMap) + } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MutableApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MutableApiConfigsManager.kt new file mode 100644 index 0000000000..35107ad6d8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/MutableApiConfigsManager.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import kotlinx.coroutines.flow.Flow + +/** + * Mutable [ApiConfigsManager] for change information about the current api environment + * +[REDACTED_AUTHOR] + */ +interface MutableApiConfigsManager : ApiConfigsManager { + + /** Api configs with current [ApiEnvironment] */ + val configs: Flow> + + /** Change api environment [environment] by [id] */ + suspend fun changeEnvironment(id: String, environment: ApiEnvironment) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManager.kt new file mode 100644 index 0000000000..fd5638c151 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManager.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.api.common.config.managers + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiConfigs +import com.tangem.datasource.api.common.config.ApiEnvironmentConfig + +/** + * Implementation of [ApiConfigsManager] in PROD environment + * + * @property apiConfigs api configs + */ +internal class ProdApiConfigsManager( + private val apiConfigs: ApiConfigs, +) : ApiConfigsManager { + + override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig { + val config = apiConfigs.firstOrNull { it.id == id } + ?: error("Api config with id [$id] not found. Check that ApiConfig with id [$id] was provided into DI") + + return config.environmentConfigs.firstOrNull { it.environment == config.defaultEnvironment } + ?: error( + "Api config with id [$id] doesn't contain environment [${config.defaultEnvironment}]. " + + "Check ApiConfig's environments is included default environment", + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt index 014f9adf75..c1cc2d5702 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt @@ -16,18 +16,20 @@ interface TangemTechMarketsApi { @Query("offset") offset: Int, @Query("limit") limit: Int, @Query("order") order: String, - @Query("general_coins") generalCoins: Boolean, @Query("search") search: String?, + @Query("timestamp") timestamp: Long?, ): ApiResponse @GET("coins/{coin_id}") suspend fun getCoinMarketData( @Path("coin_id") coinId: String, @Query("currency") currency: String, - ): ApiResponse + @Query("language") language: String, + ): ApiResponse @GET("coins/{coin_id}/history") suspend fun getCoinChart( + @Path("coin_id") coinId: String, @Query("currency") currency: String, @Query("interval") interval: String, ): ApiResponse diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketDetailsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt similarity index 50% rename from core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketDetailsResponse.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt index 227496a66e..ef485e1829 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketDetailsResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketInfoResponse.kt @@ -3,130 +3,129 @@ package com.tangem.datasource.api.markets.models.response import com.squareup.moshi.Json import java.math.BigDecimal -data class TokenMarketDetailsResponse( +data class TokenMarketInfoResponse( @Json(name = "id") val id: String, @Json(name = "name") val name: String, @Json(name = "symbol") val symbol: String, - @Json(name = "active") - val active: Boolean, @Json(name = "current_price") val currentPrice: BigDecimal, @Json(name = "price_change_percentage") - val priceChangePercentage: PriceChangePercentage, + val priceChangePercentage: PriceChangePercentage?, @Json(name = "networks") - val networks: List, + val networks: List?, @Json(name = "short_description") val shortDescription: String?, @Json(name = "full_description") val fullDescription: String?, @Json(name = "insights") - val insights: List?, + val insights: Insights?, @Json(name = "metrics") - val metrics: Metrics, + val metrics: Metrics?, @Json(name = "links") - val links: Links, + val links: Links?, @Json(name = "price_performance") - val pricePerformance: PricePerformance, + val pricePerformance: PricePerformance?, ) { + data class PriceChangePercentage( @Json(name = "24h") - val h24: BigDecimal, + val day: BigDecimal?, @Json(name = "1w") - val week1: BigDecimal, + val week: BigDecimal?, @Json(name = "1m") - val month1: BigDecimal, + val month: BigDecimal?, @Json(name = "3m") - val month3: BigDecimal, + val threeMonths: BigDecimal?, @Json(name = "6m") - val month6: BigDecimal, + val sixMonths: BigDecimal?, @Json(name = "1y") - val year1: BigDecimal, + val year: BigDecimal?, @Json(name = "all_time") - val allTime: BigDecimal, + val allTime: BigDecimal?, ) data class Network( @Json(name = "network_id") val networkId: String, @Json(name = "exchangeable") - val exchangeable: Boolean, + val exchangeable: Boolean = false, @Json(name = "contract_address") - val contractAddress: String, - @Json(name = "decimalCount") - val decimalCount: Int, + val contractAddress: String?, + @Json(name = "decimal_count") + val decimalCount: Int?, ) - data class Insight( + data class Insights( @Json(name = "holders_change") - val holdersChange: Change, + val holdersChange: Change?, @Json(name = "liquidity_change") - val liquidityChange: Change, + val liquidityChange: Change?, @Json(name = "buy_pressure_change") - val buyPressureChange: Change, + val buyPressureChange: Change?, @Json(name = "experienced_buyer_change") - val experiencedBuyerChange: Change, - ) { - data class Change( - @Json(name = "1d") - val day1: Int, - @Json(name = "1w") - val week1: Int, - @Json(name = "1m") - val month1: Int, - ) - } + val experiencedBuyerChange: Change?, + ) + + data class Change( + @Json(name = "24h") + val day: BigDecimal?, + @Json(name = "1w") + val week: BigDecimal?, + @Json(name = "1m") + val month: BigDecimal?, + ) data class Metrics( @Json(name = "market_rating") - val marketRating: Int, + val marketRating: Int?, @Json(name = "circulating_supply") - val circulatingSupply: BigDecimal, + val circulatingSupply: BigDecimal?, @Json(name = "market_cap") - val marketCap: BigDecimal, + val marketCap: BigDecimal?, @Json(name = "volume_24h") - val volume24h: BigDecimal, + val volume24h: BigDecimal?, @Json(name = "total_supply") - val totalSupply: BigDecimal, + val totalSupply: BigDecimal?, @Json(name = "fully_diluted_valuation") - val fullyDilutedValuation: BigDecimal, + val fullyDilutedValuation: BigDecimal?, ) data class Links( @Json(name = "official_links") - val officialLinks: List = emptyList(), + val officialLinks: List?, @Json(name = "social") - val social: List = emptyList(), + val social: List?, @Json(name = "repository") - val repository: List = emptyList(), + val repository: List?, @Json(name = "blockchain_site") - val blockchainSite: List = emptyList(), + val blockchainSite: List?, ) data class Link( @Json(name = "title") - val title: String?, + val title: String, @Json(name = "id") - val id: String, + val id: String?, @Json(name = "link") - val url: String, + val link: String, ) data class PricePerformance( - @Json(name = "high_price") - val highPrice: Price, + @Json(name = "24h") + val day: Range?, + @Json(name = "1m") + val month: Range?, + @Json(name = "all_time") + val allTime: Range?, + ) + + data class Range( @Json(name = "low_price") - val lowPrice: Price, - ) { - data class Price( - @Json(name = "24h") - val h24: BigDecimal, - @Json(name = "1m") - val month1: BigDecimal, - @Json(name = "all_time") - val allTime: BigDecimal, - ) - } + val low: BigDecimal?, + @Json(name = "high_price") + val high: BigDecimal?, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt index b1e8675206..a0f287971f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt @@ -14,6 +14,8 @@ data class TokenMarketListResponse( val limit: Int, @Json(name = "offset") val offset: Int, + @Json(name = "timestamp") + val timestamp: Long? = null, ) { data class Token( @Json(name = "id") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt index 7a4f33b288..6138444c45 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/AddressArgumentDTO.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.api.stakekit.models.response.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.math.BigDecimal @JsonClass(generateAdapter = true) data class AddressArgumentDTO( @@ -10,7 +11,7 @@ data class AddressArgumentDTO( @Json(name = "network") val network: String? = null, @Json(name = "minimum") - val minimum: Double? = null, + val minimum: BigDecimal? = null, @Json(name = "maximum") - val maximum: Double? = null, + val maximum: BigDecimal? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt index 5be46fc967..1cd438732e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt @@ -19,7 +19,7 @@ data class BalanceDTO( @Json(name = "groupId") val groupId: String, @Json(name = "type") - val type: BalanceType, + val type: BalanceTypeDTO, @Json(name = "amount") val amount: BigDecimal, @Json(name = "date") @@ -38,7 +38,7 @@ data class BalanceDTO( val providerId: String?, ) { - enum class BalanceType { + enum class BalanceTypeDTO { @Json(name = "available") AVAILABLE, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt index bf8956f11f..cff84d1ce7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldDTO.kt @@ -105,13 +105,13 @@ data class YieldDTO( @Json(name = "type") val type: String, @Json(name = "rewardSchedule") - val rewardSchedule: String, + val rewardSchedule: RewardScheduleDTO, @Json(name = "cooldownPeriod") val cooldownPeriod: PeriodDTO, @Json(name = "warmupPeriod") val warmupPeriod: PeriodDTO, @Json(name = "rewardClaiming") - val rewardClaiming: String, + val rewardClaiming: RewardClaimingDTO, @Json(name = "defaultValidator") val defaultValidator: String?, @Json(name = "minimumStake") @@ -135,6 +135,41 @@ data class YieldDTO( @Json(name = "enabled") val enabled: Boolean, ) + + enum class RewardScheduleDTO { + @Json(name = "block") + BLOCK, + + @Json(name = "week") + WEEK, + + @Json(name = "hour") + HOUR, + + @Json(name = "day") + DAY, + + @Json(name = "month") + MONTH, + + @Json(name = "era") + ERA, + + @Json(name = "epoch") + EPOCH, + + UNKNOWN, + } + + enum class RewardClaimingDTO { + @Json(name = "auto") + AUTO, + + @Json(name = "manual") + MANUAL, + + UNKNOWN, + } } enum class RewardTypeDTO { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt index 30396de002..9ad42062ca 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/error/StakeKitErrorResponse.kt @@ -16,7 +16,7 @@ class StakeKitErrorResponse( @Json(name = "code") val code: String? = null, @Json(name = "countryCode") - val countryCode: String, + val countryCode: String?, @Json(name = "regionCode") val regionCode: String? = null, @Json(name = "tags") diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt b/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt index 36bba25d83..af8b2a28c7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt @@ -1,7 +1,6 @@ package com.tangem.datasource.config import com.tangem.blockchain.common.* -import com.tangem.datasource.BuildConfig import com.tangem.datasource.config.ConfigManager.Companion.IS_CREATING_TWIN_CARDS_ALLOWED import com.tangem.datasource.config.ConfigManager.Companion.IS_TOP_UP_ENABLED import com.tangem.datasource.config.models.Config @@ -109,7 +108,8 @@ internal class ConfigManagerImpl @Inject constructor() : ConfigManager { sprinklr = configValues.sprinklr, walletConnectProjectId = configValues.walletConnectProjectId, tangemComAuthorization = configValues.tangemComAuthorization, - express = if (BuildConfig.ENVIRONMENT == "dev") configValues.devExpress else configValues.express, + express = configValues.express, + devExpress = configValues.devExpress, stakeKitApiKey = configValues.stakeKitApiKey, ) } @@ -154,6 +154,8 @@ internal class ConfigManagerImpl @Inject constructor() : ConfigManager { zkSyncEra = GetBlockAccessToken(rest = accessTokens.zksync?.jsonRPC), polygonZkEvm = GetBlockAccessToken(rest = accessTokens.polygonZkevm?.jsonRPC), base = GetBlockAccessToken(rest = accessTokens.base?.jsonRPC), + blast = GetBlockAccessToken(jsonRpc = accessTokens.blast?.jsonRPC), + filecoin = GetBlockAccessToken(jsonRpc = accessTokens.filecoin?.jsonRPC), ) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt b/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt index 3f54641502..2b8cadac71 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt @@ -17,5 +17,6 @@ data class Config( val walletConnectProjectId: String = "", val tangemComAuthorization: String? = null, val express: ExpressModel? = null, + val devExpress: ExpressModel? = null, val stakeKitApiKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt b/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt index a6929291e1..7a65c4ed6c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt @@ -76,6 +76,8 @@ data class GetBlockAccessTokens( @Json(name = "polygon-zkevm") val polygonZkevm: GetBlockToken?, @Json(name = "zksync") val zksync: GetBlockToken?, @Json(name = "base") val base: GetBlockToken?, + @Json(name = "blast") val blast: GetBlockToken?, + @Json(name = "filecoin") val filecoin: GetBlockToken?, ) @JsonClass(generateAdapter = true) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt new file mode 100644 index 0000000000..9959cd7ab7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -0,0 +1,32 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.Express +import com.tangem.datasource.api.common.config.TangemTech +import com.tangem.datasource.config.ConfigManager +import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.utils.version.AppVersionProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(SingletonComponent::class) +internal object ApiConfigsModule { + + @Provides + @IntoSet + fun provideExpressConfig( + configManager: ConfigManager, + expressAuthProvider: ExpressAuthProvider, + appVersionProvider: AppVersionProvider, + ): ApiConfig { + return Express(configManager, expressAuthProvider, appVersionProvider) + } + + @Provides + @IntoSet + fun provideTangemTechConfig(): ApiConfig = TangemTech() +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 6951732450..8ab0db4010 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,11 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiConfigs +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.datasource.api.common.config.managers.DevApiConfigsManager +import com.tangem.datasource.api.common.config.managers.ProdApiConfigsManager import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.markets.TangemTechMarketsApi @@ -10,12 +15,14 @@ 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,27 +39,37 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) class NetworkModule { + @Provides + @Singleton + fun provideApiConfigManager( + apiConfigs: ApiConfigs, + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): ApiConfigsManager { + return if (BuildConfig.TESTER_MENU_ENABLED) { + DevApiConfigsManager(apiConfigs, appPreferencesStore, dispatchers) + } else { + ProdApiConfigsManager(apiConfigs) + } + } + @Provides @Singleton fun provideExpressApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, - expressAuthProvider: ExpressAuthProvider, - appVersionProvider: AppVersionProvider, + apiConfigsManager: ApiConfigsManager, ): TangemExpressApi { - val url = if (BuildConfig.ENVIRONMENT == "dev") { - STAGE_EXPRESS_BASE_URL - } else { - PROD_EXPRESS_BASE_URL - } + val environmentConfig = apiConfigsManager.getEnvironmentConfig(id = ApiConfig.ID.Express) + return Retrofit.Builder() .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) - .baseUrl(url) + .baseUrl(environmentConfig.baseUrl) .client( OkHttpClient.Builder() - .addHeaders(Express(expressAuthProvider)) - .addHeaders(AppVersionPlatformHeaders(appVersionProvider)) + .addEnvironmentSwitcher(ApiConfig.ID.Express, apiConfigsManager) + .addHeaders(environmentConfig.headers) .addLoggers(context) .build(), ) @@ -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.getEnvironmentConfig(id = ApiConfig.ID.TangemTech).baseUrl, + ) } @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.getEnvironmentConfig(id = ApiConfig.ID.TangemTech).baseUrl, 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..09387b4279 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,7 +3,11 @@ package com.tangem.datasource.utils import android.content.Context import com.chuckerteam.chucker.api.ChuckerInterceptor import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.SwitchEnvironmentInterceptor +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.createNetworkLoggingInterceptor +import com.tangem.utils.Provider import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -13,7 +17,7 @@ internal fun OkHttpClient.Builder.addHeaders(vararg requestHeaders: RequestHeade Interceptor { chain -> val request = chain.request().newBuilder().apply { requestHeaders - .flatMap(RequestHeader::values) + .flatMap { it.values.toList() } .forEach { addHeader(it.first, it.second.invoke()) } }.build() @@ -22,10 +26,23 @@ internal fun OkHttpClient.Builder.addHeaders(vararg requestHeaders: RequestHeade ) } +/** Extension for adding headers [requestHeaders] to every [OkHttpClient] request */ +internal fun OkHttpClient.Builder.addHeaders(requestHeaders: Map>): OkHttpClient.Builder { + return addInterceptor( + Interceptor { chain -> + val request = chain.request().newBuilder().apply { + requestHeaders.forEach { addHeader(it.key, it.value.invoke()) } + }.build() + + chain.proceed(request) + }, + ) +} + /** * Extension for logging each [OkHttpClient] request * - * @param level logging level. By default, only the request body. + * @param context context */ internal fun OkHttpClient.Builder.addLoggers(context: Context? = null): OkHttpClient.Builder { return if (BuildConfig.LOG_ENABLED) { @@ -36,4 +53,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 = SwitchEnvironmentInterceptor( + id = id, + apiConfigsManager = apiConfigsManager, + ), + ) + } else { + this + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt index 215b56e9f8..b69481c34c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt @@ -1,8 +1,8 @@ package com.tangem.datasource.utils import com.tangem.datasource.api.common.AuthProvider -import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider +import com.tangem.utils.Provider import com.tangem.utils.version.AppVersionProvider /** @@ -10,31 +10,25 @@ import com.tangem.utils.version.AppVersionProvider * * @param pairs header name and header value pairs */ -sealed class RequestHeader(vararg pairs: Pair String>) { +sealed class RequestHeader(vararg pairs: Pair>) { /** Header list */ - val values: List String>> = pairs.toList() + val values: Map> = pairs.toMap() - data object CacheControlHeader : RequestHeader("Cache-Control" to { "max-age=600" }) + data object CacheControlHeader : RequestHeader("Cache-Control" to Provider { "max-age=600" }) class AuthenticationHeader(authProvider: AuthProvider) : RequestHeader( - "card_id" to { authProvider.getCardId() }, - "card_public_key" to { authProvider.getCardPublicKey() }, - ) - - class Express(expressAuthProvider: ExpressAuthProvider) : RequestHeader( - "api-key" to { expressAuthProvider.getApiKey() }, - "user-id" to { expressAuthProvider.getUserId() }, - "session-id" to { expressAuthProvider.getSessionId() }, + "card_id" to Provider(authProvider::getCardId), + "card_public_key" to Provider(authProvider::getCardPublicKey), ) class AppVersionPlatformHeaders(appVersionProvider: AppVersionProvider) : RequestHeader( - "version" to { appVersionProvider.versionName }, - "platform" to { "android" }, + "version" to Provider(appVersionProvider::versionName), + "platform" to Provider { "android" }, ) class StakeKit(stakeKitAuthProvider: StakeKitAuthProvider) : RequestHeader( - "X-API-KEY" to { stakeKitAuthProvider.getApiKey() }, - "accept" to { "application/json" }, + "X-API-KEY" to Provider(stakeKitAuthProvider::getApiKey), + "accept" to Provider { "application/json" }, ) } \ 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..135ca3e209 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -0,0 +1,76 @@ +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 io.mockk.mockk +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(API_CONFIGS) + + @Test + fun test_getBaseUrl() { + val actual = manager.getEnvironmentConfig(id = model.id).baseUrl + + Truth.assertThat(actual).isEqualTo(model.expected) + } + + data class Model(val id: ApiConfig.ID, val expected: String) + + private companion object { + + val API_CONFIGS = setOf( + Express(mockk(), mockk(), mockk()), + TangemTech(), + + // Don't forget to add new config + ) + + @JvmStatic + @Parameterized.Parameters + fun data(): Collection = API_CONFIGS.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 = "https://api.tangem-tech.com/v1/", + ) + } + } +} \ No newline at end of file diff --git a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt index bf84d3e382..dcd5c85434 100644 --- a/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt +++ b/core/deep-links/src/main/kotlin/com/tangem/core/deeplink/impl/DefaultDeepLinksRegistry.kt @@ -18,7 +18,7 @@ internal class DefaultDeepLinksRegistry : DeepLinksRegistry { val received = intent.data ?: return false var hasMatch = false - Timber.d( + Timber.i( """ Received deep link intent |- Received URI: $received @@ -33,7 +33,7 @@ internal class DefaultDeepLinksRegistry : DeepLinksRegistry { val params = getParams(expected, received) - Timber.d( + Timber.i( """ Matched deep link |- Expected URI: $expected @@ -45,7 +45,7 @@ internal class DefaultDeepLinksRegistry : DeepLinksRegistry { } if (!hasMatch) { - Timber.d( + Timber.i( """ No match found for deep link |- Received URI: $received diff --git a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json index 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-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 6d99e6bd52..e0da81b392 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1,7 +1,7 @@ Netzwerk wählen - Benutzerdef. Token hinzuf. + Token hinzuf. Token verwalten Sende nur %1$s ( %2$s ) vom %3$s -Netzwerk an diese Adresse. Die Verwendung anderer Token und Netzwerke kann zum Verlust von Geldern führen. So scannt man @@ -88,7 +88,7 @@ Kopieren Adresse kopieren Erstellen - Benutzerdef. + Eigen %d tag %d tage @@ -126,6 +126,7 @@ Passphrase Einfügen %1$s-%2$s + %1$s — %2$s Weiterlesen Empfangen Ablehnen @@ -278,7 +279,7 @@ Rückmeldung Feedback zu Tangem Eine Transaktion kann nicht gesendet werden - Aktuelle Transaktion + diese Transaktion Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Sie die Verwendung Ihres Tokens für den Swap genehmigen. Gib das Genehmigungslimit für das ausgewählte Token an Betrag %s @@ -341,7 +342,7 @@ Verfügbare Netzwerke Mein Portfolio Markt - Um Adressen für ausgewählte Netzwerke zu generieren, musst du eine Tangem-Karte scannen/ einsetzen + Um Adressen für ausgewählte Netzwerke zu generieren, musst du eine Tangem-Karte scannen Die Daten konnten nicht geladen werden... Schnelle Aktionen Ergebnis @@ -446,7 +447,7 @@ Möchtest du den Aktivierungsprozess abbrechen? Erste Schritte Für die Karte, die du hinzufügen möchtest, wurde bereits eine andere Wallets erstellt. Wenn du Guthaben auf dieser Wallets hast, hebe es bitte ab, setze diese Karte zurück und füge sie als Backup hinzu. - Erstellen eines Backups + Backups erstellen Lese mehr über die Seed-Phrase leer @@ -499,7 +500,7 @@ Gruppe erstellen Nach Guthaben Token organisieren - Gruppierung aufh. + Grup. entf. Wählen aus der Galerie aus Einstellungen Du hast keinen Zugriff auf deine Kamera gewährt @@ -671,7 +672,6 @@ Die zugewiesene Zeit für die Aktivierung der Teilnahme am Staking. Migrieren Natives Staking - Mit Staking kannst du %1$s verdienen. Deine Staking-Belohnungen kommen alle ~%2$s Tage. Verdiene Staking-Belohnungen Die Belohnungen werden sofort nach dem unstaken gestoppt. Der unstakingprozess dauert %s. Erneut binden @@ -683,6 +683,7 @@ händisch Block Tag + Täglich Epoche Ära Stunde @@ -834,7 +835,7 @@ Aktivierungsfehler Laut den Entwicklern des BNB-Netzes wird die Unterstützung für den BEP-2-Standard im Juni 2024 enden. Um den Verlust von Vermögenswerten mit diesem Standard zu vermeiden, konvertiere bitte in den BEP-20 Standard. Nutze gerne unseren Swap-Service, um sie auf das BNB Smart Chain Netzwerk zu übertragen. BNB Beacon Chain wird abgeschaltet - ausbaufähig + Ausbaufähig Gefällt mir OK, habe ich verstanden! Echt toll! diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 3c55b9ec8c..5e041ee2f3 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -123,6 +123,7 @@ Passphrase Coller %1$s-%2$s + %1$s — %2$s En savoir plus Recevoir Rejeter @@ -269,7 +270,7 @@ Commentaires Commentaires sur Tangem Impossible d\'envoyer une transaction - Transaction en cours + Transaction Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange. Spécifiez la limite approuvée pour le jeton sélectionné Montant %s @@ -328,7 +329,7 @@ Ajouter au portfolio Mon portfolio Marché - Pour générer des adresses pour les réseaux sélectionnés, vous devez scanner votre carte Tangem. + Pour générer des adresses pour les réseaux sélectionnés, vous devez scanner votre carte Tangem Sélectionnez un portefeuille Trier par Idées @@ -594,7 +595,6 @@ Période d\'échauffement Le temps imparti pour activer la participation au staking. Native staking - Le staking vous permet d\'en gagner %1$s. Vos récompenses de staking arrivent tous les ~%2$s jours. Gagnez des récompenses de staking Récompenses Staker plus diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index b8860bd7d8..e7caa7829e 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -14,7 +14,6 @@ Mantieni le modifiche Invia Con successo - Avviso Codice di accesso Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto Mantenimento della carta diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index d5d7502641..8ebb799edb 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -276,7 +276,7 @@ フィードバック Tangemへのフィードバック 取引を送信できません - 現在の取引 + 取引 ネットワークは、あなたがトークンのスワップを承認していることを確認するために、トークン承認手数料を請求します。 選択したトークンの承認制限を指定します 金額%s @@ -636,7 +636,6 @@ 資産のステーキングを解除するには、ここをクリックしてください。 ステーキング金額は %s 以上である必要があります ステーキング解除分を請求する - APY ステーキングに参加することで得られる年間収益率。 APR 利用可能 @@ -659,7 +658,6 @@ ステーキングへの参加を有効にするために割り当てられた時間。 移行 ネイティブステーキング - ステーキングにより%1$sを獲得できます。ステーキング報酬は ~ %2$s日ごとに届きます。 ステーキング報酬を獲得 ステーキング解除後、報酬の獲得はすぐに停止します。ステーキング解除プロセスには%sかかります。 再結束 @@ -671,6 +669,7 @@ 手動 ブロック + 毎日 エポック 時代 @@ -686,6 +685,7 @@ 資産を請求するために、unstakedを確認してください ステーキング解除 バリデーター + バリデーター 投票する 投票はロックされています 引き出す diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index f7c6ecf61c..9df99375b9 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -47,7 +47,7 @@ Отключите эту опцию, если не хотите, чтобы эта карта использовалась для сброса кодов доступа на другие карты этого кошелька. Обратите внимание, сброс кода также не будет доступен на этой карте. Использовать эту карту для сброса кода доступа на других картах в этом кошельке - Восстановление кода доступа + Восстановление доступа Сбросить Вы уверены, что хотите это сделать? Смена кода доступа @@ -128,6 +128,7 @@ Парольная фраза Вставить %1$s-%2$s + %1$s — %2$s Подробнее Получить Отклонить @@ -628,7 +629,6 @@ Для завершения стейкинга нажмите сюда Сумма для стейкинга должна быть не менее %s Забрать средства - APY Годовой процентный доход, который вы можете получить от участия в стейкинге. APR Доступно @@ -650,13 +650,26 @@ Время, необходимое для начала процесса стейкинга и активации процесса начисления наград Переместить Нативный стейкинг - Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый %2$s + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый день. + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый час. + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждый месяц. + Стейкинг дает возможность вам получать %1$s. Награда будет зачисляться каждую неделю. Получите награду за стейкинг Награда перестанет начисляться сразу после завершения стейкинга. Процесс завершения длится %s. Повторный стейкинг Застейкать вознаграждения Отозвать Переголосовать + Автоматически + Вручную + Блок + День + Каждый день + Эпоха + Эра + Час + Месяц + Неделя Вознаграждения Застейкать еще Застейкать %s diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index bdb802eb7e..d352a7be3c 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -90,6 +90,7 @@ Копіювати Скопіювати адресу Створити + %1$s (%2$s) Власна %d день @@ -130,6 +131,7 @@ Парольна фраза Вставити %1$s-%2$s + %1$s — %2$s Детальніше Отримати Відхилити @@ -282,7 +284,7 @@ Звернення в підтримку Звернення в підтримку Tangem Не вдається відправити транзакцію - Поточна транзакція + Транзакція Мережа стягує комісію за схвалення токену за підтвердження, що саме ви дозволяєте використовувати ваш токен для обміну. Вкажіть ліміт доступу для обраного токена Кількість %s @@ -347,7 +349,7 @@ Доступні мережі Моє портфоліо Маркет - Щоб згенерувати адреси для обраних мереж, потрібно відсканувати свою картку Tangem. + Щоб згенерувати адреси для обраних мереж, потрібно відсканувати свою картку Tangem Не вдалося завантажити дані... Швидкі дії Результат @@ -650,7 +652,6 @@ Транзакція успішно підписана і відправлена до блокчейну. Баланс гаманця буде оновлено через деякий час %1$s — це монета у мережі Tron. Щоб розрахувати комісію та здійснити транзакцію, вам необхідно внести певну кількість Tron (TRX) на свій рахунок. Недійсна адреса - %1$s (%2$s) Трансакцію надіслано Підготуйтеся до сканування картку, яку потрібно налаштувати. Забути гаманець @@ -682,7 +683,10 @@ Відведений час для активації участі в стейкінгу. Перемістити Нативний стейкінг - Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг надходять кожні ~%2$s днів. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щодня. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються кожну годину. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щомісяця. + Стейкінг дозволяє заробляти %1$s. Ваші винагороди за стейкінг зараховуються щотиждня. Отримуйте винагороду за стейкінг Винагороди припиняють нараховуватися одразу після того, як ви знімаєте ставку. Процес зняття займає %s. Зʼєднати ще раз diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index b198f3eee7..eb5bc2de81 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -9,9 +9,6 @@ 原因:%s 無法發送交易 此卡不支持%1$s網路上的代幣因為韌體限制 - 感謝您的反饋。我們會盡快回复 - 你的建議已送出 - 請嘗試完全按照動畫中顯示的方式點擊卡片或請求支持 有困難在掃描卡上嗎? 此卡不適用於此app 轉到設置以在 Tangem App 中啟用生物識別身份驗證 @@ -48,7 +45,7 @@ 生物 購買 您尚未授予相機訪問權限,請更改您的隱私設置 - 刪除 + 删除 關閉 繼續 複製 @@ -56,7 +53,6 @@ 創造 刪除 禁用 - 斷開連接 完成 允許 啟用 @@ -69,7 +65,6 @@ 主卡片 拒絕 重新命名 - 重試 保存設置 搜索 搜尋代幣 @@ -88,7 +83,6 @@ 交易 我了解 無法觸達 - 警告 已複製代幣地址 支持的網路 @@ -121,7 +115,6 @@ App Currency 發行人 簽署 - 如果您忘記密碼,您將無法使用您的資金。無法恢復代碼 更多 檢查您的網路連接或切換到其他網絡 服務條款 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 5a4b8c08bb..956b064d05 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -88,6 +88,7 @@ Copy Copy address Create + %1$s (%2$s) Custom %d day @@ -126,6 +127,7 @@ Passphrase Paste %1$s-%2$s + %1$s — %2$s Read more Receive Reject @@ -634,7 +636,6 @@ Transaction has been successfully signed and sent to the blockchain node. Wallet balance will be updated in a while %1$s is an asset in the Tron network. To calculate the fee and make a transaction you need to deposit some Tron (TRX) in your account. Invalid address - %1$s (%2$s) Transaction sent Prepare to scan card you want to setup. Forget wallet @@ -667,7 +668,10 @@ The allocated time for activating participation in staking. Migrate Native staking - Staking allow you to earn %1$s. Your staking rewards arrive every ~%2$s days. + Staking allow you to earn %1$s. Your staking rewards arrive every day. + Staking allow you to earn %1$s. Your staking rewards arrive every hour. + Staking allow you to earn %1$s. Your staking rewards arrive every month. + Staking allow you to earn %1$s. Your staking rewards arrive every week. Earn staking rewards Rewards stop accruing immediately after you unstake. The unstaking process takes %s. Rebond @@ -679,6 +683,7 @@ Manual Block Day + Each day Epoch Era Hour 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/marketprice/PriceChangeInPercent.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt index d2c55ed576..a202d8bf21 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt @@ -1,10 +1,7 @@ package com.tangem.core.ui.components.marketprice import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,6 +23,11 @@ fun PriceChangeInPercent( modifier: Modifier = Modifier, textStyle: TextStyle = TangemTheme.typography.body2, ) { + if (valueInPercent.isBlank()) { + Box(modifier) + return + } + Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt index 110080210d..801d47263b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/text/TooltipText.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -28,7 +29,7 @@ fun TooltipText( text: TextReference, onInfoClick: () -> Unit, modifier: Modifier = Modifier, - useSmallerText: Boolean = false, + textStyle: TextStyle = TangemTheme.typography.caption2, ) { val interactionSource = remember { MutableInteractionSource() } @@ -45,18 +46,16 @@ fun TooltipText( Text( modifier = Modifier.weight(1f, fill = false), text = text.resolveReference(), - style = if (useSmallerText) { - TangemTheme.typography.caption2 - } else { - TangemTheme.typography.subtitle2 - }, + style = textStyle, color = TangemTheme.colors.text.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) IconButton( - modifier = Modifier.requiredSize(TangemTheme.dimens.size24), + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing4) + .requiredSize(TangemTheme.dimens.size16), interactionSource = interactionSource, onClick = onInfoClick, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index 33a8bd032e..f59561c295 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -3,7 +3,7 @@ package com.tangem.core.ui.extensions import androidx.annotation.DrawableRes import com.tangem.core.ui.R -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getActiveIconRes(blockchainId: String): Int { return when (blockchainId) { @@ -70,11 +70,14 @@ fun getActiveIconRes(blockchainId: String): Int { "joystream" -> R.drawable.img_joystream_22 "koinos", "koinos/test" -> R.drawable.img_koinos_22 "bittensor" -> R.drawable.img_bittensor_22 + "blast", "blast/test" -> R.drawable.img_blast_22 + "filecoin" -> R.drawable.img_filecoin_22 + "cyber", "cyber/test" -> R.drawable.img_cyber_22 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getActiveIconResByNetworkId(networkId: String): Int { return when (networkId) { @@ -141,6 +144,9 @@ fun getActiveIconResByNetworkId(networkId: String): Int { "joystream" -> R.drawable.img_joystream_22 "koinos", "koinos/test" -> R.drawable.img_koinos_22 "bittensor" -> R.drawable.img_bittensor_22 + "blast", "blast/test" -> R.drawable.img_blast_22 + "filecoin" -> R.drawable.img_filecoin_22 + "cyber", "cyber/test" -> R.drawable.img_cyber_22 else -> R.drawable.ic_alert_24 } } @@ -209,11 +215,14 @@ fun getActiveIconResByCoinId(coinId: String): Int { "joystream" -> R.drawable.img_joystream_22 "koinos", "koinos/test" -> R.drawable.img_koinos_22 "bittensor" -> R.drawable.img_bittensor_22 + "blast", "blast/test" -> R.drawable.img_blast_22 + "filecoin" -> R.drawable.img_filecoin_22 + "cyber", "cyber/test" -> R.drawable.img_cyber_22 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getGreyedOutIconRes(blockchainId: String): Int { return when (blockchainId) { @@ -280,11 +289,14 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "joystream" -> R.drawable.ic_joystream_22 "koinos", "koinos/test" -> R.drawable.ic_koinos_22 "bittensor" -> R.drawable.ic_bittensor_22 + "blast", "blast/test" -> R.drawable.ic_blast_22 + "filecoin" -> R.drawable.ic_filecoin_22 + "cyber", "cyber/test" -> R.drawable.ic_cyber_22 else -> R.drawable.ic_alert_24 } } -@Suppress("ComplexMethod") +@Suppress("ComplexMethod", "LongMethod") @DrawableRes fun getGreyedOutIconResByNetworkId(networkId: String): Int { return when (networkId) { @@ -351,6 +363,9 @@ fun getGreyedOutIconResByNetworkId(networkId: String): Int { "joystream" -> R.drawable.ic_joystream_22 "koinos", "koinos/test" -> R.drawable.ic_koinos_22 "bittensor" -> R.drawable.ic_bittensor_22 + "blast", "blast/test" -> R.drawable.ic_blast_22 + "filecoin" -> R.drawable.ic_filecoin_22 + "cyber", "cyber/test" -> R.drawable.ic_cyber_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/DefaultHapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/DefaultHapticManager.kt new file mode 100644 index 0000000000..5a34d92702 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/DefaultHapticManager.kt @@ -0,0 +1,32 @@ +package com.tangem.core.ui.haptic + +import android.view.View +import androidx.core.view.ViewCompat + +internal class DefaultHapticManager( + private val view: View, + private val vibratorHapticManager: VibratorHapticManager?, +) : HapticManager { + + override fun perform(effect: TangemHapticEffect) { + when (effect) { + is TangemHapticEffect.View -> { + effect.androidHapticFeedbackCode?.let { + ViewCompat.performHapticFeedback(view, it) + } + } + is TangemHapticEffect.OneTime -> { + if (vibratorHapticManager != null) { + vibratorHapticManager.performOneTime(effect) + } else { + when (effect) { + TangemHapticEffect.OneTime.Tick -> perform(TangemHapticEffect.View.SegmentTick) + TangemHapticEffect.OneTime.Click -> perform(TangemHapticEffect.View.ContextClick) + TangemHapticEffect.OneTime.DoubleClick -> perform(TangemHapticEffect.View.ContextClick) + TangemHapticEffect.OneTime.HeavyClick -> perform(TangemHapticEffect.View.LongPress) + } + } + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt index b93c08bd26..9b0d3cd124 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/HapticManager.kt @@ -2,12 +2,13 @@ package com.tangem.core.ui.haptic import androidx.compose.runtime.Stable +/** + * Haptic feedback. + * @see [TangemHapticEffect.OneTime] for one-time effects. + * @see [TangemHapticEffect.View] for view effects. + */ @Stable interface HapticManager { - fun vibrateShort() - - fun vibrateMeduim() - - fun vibrateLong() + fun perform(effect: TangemHapticEffect) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt deleted file mode 100644 index ad74ee1bd1..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.core.ui.haptic - -import androidx.compose.ui.hapticfeedback.HapticFeedback -import androidx.compose.ui.hapticfeedback.HapticFeedbackType - -@Suppress("FunctionName") -fun MockHapticManager(mockHapticFeedback: HapticFeedback? = null): HapticManager = - if (mockHapticFeedback == null) MockHapticManager else MockHapticManagerImpl(mockHapticFeedback) - -val MockHapticManager: HapticManager = MockHapticManagerImpl() - -private class MockHapticManagerImpl( - private val mockHapticFeedback: HapticFeedback? = null, -) : HapticManager { - - override fun vibrateShort() { - mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove) - /** Intentionally do nothing */ - } - - override fun vibrateMeduim() { - mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove) - /** Intentionally do nothing */ - } - - override fun vibrateLong() { - mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.LongPress) - /** Intentionally do nothing */ - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/TangemHapticEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/TangemHapticEffect.kt new file mode 100644 index 0000000000..4be47d804d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/TangemHapticEffect.kt @@ -0,0 +1,125 @@ +package com.tangem.core.ui.haptic + +import android.os.Build +import android.os.VibrationEffect +import androidx.annotation.RequiresApi + +sealed interface TangemHapticEffect { + + /** + * For cases when view could not be visible on screen (ex. Activity is not in foreground) + * or there is no view context (ex. background service, Model, ViewModel) + */ + enum class OneTime : TangemHapticEffect { + Click, + DoubleClick, + HeavyClick, + Tick, + ; + + val code: Int + @RequiresApi(Build.VERSION_CODES.Q) + get() = when (this) { + Click -> VibrationEffect.EFFECT_CLICK + DoubleClick -> VibrationEffect.EFFECT_DOUBLE_CLICK + HeavyClick -> VibrationEffect.EFFECT_HEAVY_CLICK + Tick -> VibrationEffect.EFFECT_TICK + } + } + + /** + * Preferred way to provide haptic feedback for UI components + * @see [androidx.core.view.HapticFeedbackConstantsCompat] + */ + @Suppress("MagicNumber") + enum class View(internal val androidHapticFeedbackCode: Int? = null) : TangemHapticEffect { + /** + * The user has performed a long press on an object that is resulting in an action being + * performed + */ + LongPress(0), + /** + * The user has pressed on a virtual on-screen key + */ + VirtualKey(1), + /** + * The user has pressed either an hour or minute tick of a Clock + */ + ClockTick(4), + /** + * The user has performed a context click on an object + */ + ContextClick(6), + /** + * The user has pressed a virtual or software keyboard key + */ + KeyboardPress(3), + /** + * The user has released a virtual keyboard key + */ + KeyboardRelease(7), + /** + * The user has released a virtual key + */ + VirtualKeyRelease(8), + /** + * The user has performed a selection/insertion handle move on text field + */ + TextHandleMove(9), + /** + * The user has started a gesture (e.g. on the soft keyboard) + */ + GestureStart(12), + /** + * The user has finished a gesture (e.g. on the soft keyboard) + */ + GestureEnd(13), + /** + * A haptic effect to signal the confirmation or successful completion of a user interaction + */ + Confirm(16), + /** + * A haptic effect to signal the rejection or failure of a user interaction + */ + Reject(17), + /** + * The user has toggled a switch or button into the on position + */ + ToggleOn(21), + /** + * The user has toggled a switch or button into the off position + */ + ToggleOff(22), + /** + * The user is executing a swipe/drag-style gesture, such as pull-to-refresh, where the + * gesture action is “eligible” at a certain threshold of movement, and can be cancelled by + * moving back past the threshold. This constant indicates that the user's motion has just + * passed the threshold for the action to be activated on release + */ + GestureThresholdActivate(23), + /** + * The user is executing a swipe/drag-style gesture, such as pull-to-refresh, where the + * gesture action is “eligible” at a certain threshold of movement, and can be cancelled by + * moving back past the threshold. This constant indicates that the user's motion has just + * re-crossed back "under" the threshold for the action to be activated, meaning the gesture is + * currently in a cancelled state + */ + GestureThresholdDeactivate(24), + /** + * The user has started a drag-and-drop gesture. The drag target has just been "picked up" + */ + DragStart(25), + /** + * The user is switching between a series of potential choices, for example items in a list + * or discrete points on a slider + */ + SegmentTick(26), + /** + * The user is switching between a series of many potential choices, for example minutes on a + * clock face, or individual percentages. This constant is expected to be very soft, so as + * not to be uncomfortable when performed a lot in quick succession. If the device can’t make + * a suitably soft vibration, then it may not make any vibration + */ + SegmentFrequentTick(27), + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/VibratorHapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/VibratorHapticManager.kt new file mode 100644 index 0000000000..f152a10aca --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/VibratorHapticManager.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.haptic + +import androidx.compose.runtime.Stable + +/** + * Haptic feedback + * For cases when view could not be visible on screen (ex. Activity is not in foreground) + * or there is no view context (ex. background service, Model, ViewModel) + * @see [HapticManager] for view effects. + */ +@Stable +interface VibratorHapticManager { + + fun performOneTime(effect: TangemHapticEffect.OneTime) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt new file mode 100644 index 0000000000..2e81db1003 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt @@ -0,0 +1,26 @@ +package com.tangem.core.ui.res + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.runtime.* + +@Immutable +object TangemAnimations { + + val transitionSpecs = TransitionSpecs + + @Composable + @NonRestartableComposable + fun horizontalIndicatorAsState(targetFraction: Float): State { + return animateFloatAsState( + targetValue = targetFraction, + animationSpec = tween(durationMillis = 300), + label = "Indicator fraction", + ) + } + + @Immutable + object TransitionSpecs { + // TODO add more transition specs + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 300231f9c3..999276faf5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -8,10 +8,12 @@ import androidx.compose.material.ProvideTextStyle import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.components.TangemShimmer +import com.tangem.core.ui.haptic.DefaultHapticManager import com.tangem.core.ui.haptic.HapticManager -import com.tangem.core.ui.haptic.MockHapticManager +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.windowsize.WindowSize import com.valentinilk.shimmer.Shimmer @@ -21,7 +23,7 @@ fun TangemTheme( windowSize: WindowSize, typography: TangemTypography = TangemTheme.typography, dimens: TangemDimens = TangemTheme.dimens, - hapticManager: HapticManager = MockHapticManager, + vibratorHapticManager: VibratorHapticManager? = null, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable () -> Unit, ) { @@ -40,6 +42,12 @@ fun TangemTheme( ) } + val view = LocalView.current + + val hapticManager = remember(view) { + DefaultHapticManager(view = view, vibratorHapticManager = vibratorHapticManager) + } + MaterialTheme( colors = materialThemeColors(colors = themeColors, isDark = isDark), ) { @@ -52,10 +60,11 @@ fun TangemTheme( LocalHapticManager provides hapticManager, LocalSnackbarHostState provides snackbarHostState, LocalWindowSize provides windowSize, - LocalTextSelectionColors provides TangemTextSelectionColors, ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, + LocalMainBottomSheetColor provides remember { mutableStateOf(Color.Unspecified) }, + LocalTextSelectionColors provides TangemTextSelectionColors, ) { ProvideTextStyle( value = TangemTheme.typography.body1, @@ -208,11 +217,13 @@ private fun darkThemeColors(): TangemColors { ) } -@Stable -private val TangemTextSelectionColors = TextSelectionColors( - handleColor = TangemColorPalette.Azure, - backgroundColor = TangemColorPalette.Azure.copy(alpha = 0.4f), -) +private val TangemTextSelectionColors: TextSelectionColors + @Composable + @ReadOnlyComposable + get() = TextSelectionColors( + handleColor = TangemTheme.colors.text.accent, + backgroundColor = TangemTheme.colors.text.accent.copy(alpha = 0.3f), + ) private val LocalTangemColors = staticCompositionLocalOf { error("No TangemColors provided") @@ -246,4 +257,8 @@ val LocalWindowSize = staticCompositionLocalOf { val LocalTangemShimmer = staticCompositionLocalOf { error("No TangemShimmer provided") +} + +val LocalMainBottomSheetColor = staticCompositionLocalOf> { + error("No MainBottomSheetColor provided") } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt index c3ed096dce..0347ca4893 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt @@ -6,8 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.platform.LocalHapticFeedback -import com.tangem.core.ui.haptic.MockHapticManager import com.tangem.core.ui.windowsize.rememberWindowSizePreview @Composable @@ -29,7 +27,6 @@ fun TangemThemePreview( typography = typography, dimens = dimens, windowSize = rememberWindowSizePreview(maxWidth, maxHeight), - hapticManager = MockHapticManager(LocalHapticFeedback.current), content = content, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt b/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt index d6b1869ec5..5557002b53 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/screen/ComposeScreen.kt @@ -61,7 +61,7 @@ internal fun ComposeScreen.createComposeView(context: Context, activity: Activit TangemTheme( isDark = shouldUseDarkTheme(appThemeMode), windowSize = windowSize, - hapticManager = uiDependencies.hapticManager, + vibratorHapticManager = uiDependencies.vibratorHapticManager, snackbarHostState = uiDependencies.globalSnackbarHostState, ) { ScreenContent(modifier = screenModifier) diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt index e5d94f85b7..b0c1a83fed 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 @@ -13,18 +13,28 @@ import java.text.NumberFormat import java.util.Currency import java.util.Locale +@Suppress("LargeClass") object BigDecimalFormatter { const val EMPTY_BALANCE_SIGN = DASH_SIGN - const val CAN_BE_LOWER_SIGN = LOWER_SIGN + private const val CAN_BE_LOWER_SIGN = LOWER_SIGN private val FORMAT_THRESHOLD = BigDecimal("0.01") private const val TEMP_CURRENCY_CODE = "USD" private val FIAT_FORMAT_THRESHOLD = BigDecimal("0.01") + private val CRYPTO_FEE_FORMAT_THRESHOLD = BigDecimal("0.000001") + private const val FIAT_MARKET_DEFAULT_DIGITS = 2 private const val FIAT_MARKET_EXTENDED_DIGITS = 6 + private val bigDecimal01 = BigDecimal("0.1") + private val bigDecimal001 = BigDecimal("0.01") + private val bigDecimal0001 = BigDecimal("0.001") + private val bigDecimal00001 = BigDecimal("0.0001") + private val bigDecimal000001 = BigDecimal("0.00001") + private val bigDecimal0000001 = BigDecimal("0.000001") + fun formatCryptoAmount( cryptoAmount: BigDecimal?, cryptoCurrency: String, @@ -105,6 +115,45 @@ object BigDecimalFormatter { } } + fun formatCryptoFeeAmount( + cryptoAmount: BigDecimal?, + cryptoCurrency: String, + decimals: Int, + canBeLower: Boolean = false, + locale: Locale = Locale.getDefault(), + ): String { + if (cryptoAmount == null) return EMPTY_BALANCE_SIGN + + val formatter = NumberFormat.getNumberInstance(locale).apply { + maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6) + minimumFractionDigits = 2 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + + val amountFormatted = if (cryptoAmount.checkCryptoThreshold()) { + buildString { + append(CAN_BE_LOWER_SIGN) + append( + formatter.format(CRYPTO_FEE_FORMAT_THRESHOLD), + ) + } + } else { + buildString { + if (canBeLower) { + append(CAN_BE_LOWER_SIGN) + } + append(formatter.format(cryptoAmount)) + } + } + + return if (cryptoCurrency.isEmpty()) { + amountFormatted + } else { + amountFormatted + "\u2009$cryptoCurrency" + } + } + fun formatCryptoAmount( cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency, @@ -117,6 +166,7 @@ object BigDecimalFormatter { fiatAmount: BigDecimal?, fiatCurrencyCode: String, fiatCurrencySymbol: String, + decimals: Int = FIAT_MARKET_DEFAULT_DIGITS, locale: Locale = Locale.getDefault(), ): String { if (fiatAmount == null) return EMPTY_BALANCE_SIGN @@ -124,12 +174,12 @@ object BigDecimalFormatter { val formatterCurrency = getCurrency(fiatCurrencyCode) val formatter = NumberFormat.getCurrencyInstance(locale).apply { currency = formatterCurrency - maximumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS - minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + maximumFractionDigits = decimals + minimumFractionDigits = decimals roundingMode = RoundingMode.HALF_UP } - return if (fiatAmount.isLessThanThreshold()) { + return if (fiatAmount.checkFiatThreshold()) { buildString { append(CAN_BE_LOWER_SIGN) append( @@ -152,7 +202,7 @@ object BigDecimalFormatter { if (fiatAmount == null) return EMPTY_BALANCE_SIGN val formatterCurrency = getCurrency(fiatCurrencyCode) - val digits = if (fiatAmount.isLessThanThreshold()) { + val digits = if (fiatAmount.checkFiatThreshold()) { FIAT_MARKET_EXTENDED_DIGITS } else { FIAT_MARKET_DEFAULT_DIGITS @@ -168,6 +218,28 @@ object BigDecimalFormatter { .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } + fun formatFiatPriceUncapped( + fiatAmount: BigDecimal?, + fiatCurrencyCode: String, + fiatCurrencySymbol: String, + locale: Locale = Locale.getDefault(), + ): String { + if (fiatAmount == null) return EMPTY_BALANCE_SIGN + val formatterCurrency = getCurrency(fiatCurrencyCode) + + val decimals = getProperFiatPriceDecimals(fiatAmount) + + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + maximumFractionDigits = decimals + minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + roundingMode = RoundingMode.HALF_UP + } + + return formatter.format(fiatAmount) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) + } + fun formatFiatEditableAmount( fiatAmount: String?, fiatCurrencyCode: String, @@ -250,40 +322,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 +352,69 @@ object BigDecimalFormatter { ) } - private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD + /** + * "123456.6" -> "123.457K" + * "12345.6" -> "123.046K" + * Negative amount is not supported + * @param threeDigitsMethod if true, will format the amount always with 3 significant digits + * @param scale the number of digits to the right of the decimal point + */ + @Suppress("MagicNumber") + fun formatCompactAmount( + amount: BigDecimal, + locale: Locale = Locale.getDefault(), + threeDigitsMethod: Boolean = false, + scale: Int = 0, + ): String { + if (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.checkFiatThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD + + private fun BigDecimal.checkCryptoThreshold() = this > BigDecimal.ZERO && this < CRYPTO_FEE_FORMAT_THRESHOLD + + @Suppress("MagicNumber") + fun getProperFiatPriceDecimals(price: BigDecimal): Int { + return when { + price >= BigDecimal.ONE -> 2 + price >= bigDecimal01 -> 3 + price >= bigDecimal001 -> 4 + price >= bigDecimal0001 -> 6 + price >= bigDecimal00001 -> 8 + price >= bigDecimal000001 -> 10 + price >= bigDecimal0000001 -> 12 + else -> price.stripTrailingZeros().scale() + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt index 82aab46559..9218188bef 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatterCompat.kt @@ -7,16 +7,32 @@ import java.util.Locale internal object BigDecimalFormatterCompat { /** - * Formats value as [BigDecimalFormatter.formatCompactAmount] does using only "T","B","M","K" suffixes + * Formats value as [BigDecimalFormatter.formatCompactFiatAmount] does using only "T","B","M","K" suffixes * Used for < API24 compatibility */ @Suppress("MagicNumber", "UnnecessaryParentheses") - fun formatCompactAmountNoLocaleContext( + fun formatCompactFiatAmountNoLocaleContext( amount: BigDecimal, fiatCurrencyCode: String, fiatCurrencySymbol: String, locale: Locale = Locale.getDefault(), ): String { + val formatted = formatCompactAmountNoLocaleContext(amount) + + return BigDecimalFormatter.addCurrencySymbolToStringAmount( + amount = formatted, + fiatCurrencyCode = fiatCurrencyCode, + fiatCurrencySymbol = fiatCurrencySymbol, + locale = locale, + ) + } + + /** + * Formats value as [BigDecimalFormatter.formatCompactAmount] does using only "T","B","M","K" suffixes + * Used for < API24 compatibility + */ + @Suppress("MagicNumber", "UnnecessaryParentheses") + fun formatCompactAmountNoLocaleContext(amount: BigDecimal): String { val value = amount.setScale(0, RoundingMode.HALF_UP).longValueExact() val formatted = when { @@ -42,11 +58,6 @@ internal object BigDecimalFormatterCompat { else -> return value.toString() } - return BigDecimalFormatter.addCurrencySymbolToStringAmount( - amount = formatted, - fiatCurrencyCode = fiatCurrencyCode, - fiatCurrencySymbol = fiatCurrencySymbol, - locale = locale, - ) + return formatted } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index d42121d3f0..4dd1e38fd1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -10,15 +10,22 @@ import java.util.Locale @Suppress("MagicNumber") object DateTimeFormatters { - private const val DDMMYYYY = "dd.MM.yyyy" + /** + * Determine if the time is in 12-hour format ("10:00 PM") for the current locale. + */ + val is12HourFormat by lazy { + /** + * Two SS means, SHORT style for date and time. + * If pattern contains "a", it means time is in 12 hour format. + * [Documentation](https://www.joda.org/joda-time/apidocs/org/joda/time/format/DateTimeFormat.html) + */ + DateTimeFormat.patternForStyle("SS", Locale.getDefault()).contains("a") + } /** - * Two SS means, SHORT style for date and time. - * If pattern contains "a", it means time is in 12 hour format. - * [Documentation](https://www.joda.org/joda-time/apidocs/org/joda/time/format/DateTimeFormat.html) + * Example: "12:00 PM", "12:00" */ val timeFormatter: DateTimeFormatter by lazy { - val is12HourFormat = DateTimeFormat.patternForStyle("SS", Locale.getDefault()).contains("a") if (is12HourFormat) { DateTimeFormatterBuilder() .appendClockhourOfHalfday(1) @@ -38,6 +45,9 @@ object DateTimeFormatters { } } + /** + * Example: "1 Jun, 2020", "1 Jun, 2020" + */ val dateFormatter: DateTimeFormatter by lazy { DateTimeFormatterBuilder() .appendDayOfMonth(1) @@ -49,28 +59,61 @@ object DateTimeFormatters { .withLocale(Locale.getDefault()) } + /** + * Example: "31.06.2020", "06/31/2020" + */ val dateDDMMYYYY: DateTimeFormatter by lazy { - DateTimeFormatterBuilder() - .appendPattern(DDMMYYYY) - .toFormatter() - .withLocale(Locale.getDefault()) + getBestFormatterBySkeleton("dd.MM.yyyy") } /** - * In API version < 24, there may be some problems with getting the best date and time format pattern. + * Example: "Jun 31, 2020", "31 Jun, 2020" */ - val dateMMMMd: DateTimeFormatter by lazy { - DateTimeFormatterBuilder() - .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMM d")) - .toFormatter() - .withLocale(Locale.getDefault()) + val dateMMMdd: DateTimeFormatter by lazy { + getBestFormatterBySkeleton("MMM dd") } + /** + * Example: "2020" + */ + val dateYYYY: DateTimeFormatter by lazy { + getBestFormatterBySkeleton("yyyy") + } + + /** + * Example: "31.06.2020 12:00", "06/31/2020 12:00", "06/31/2020 12:00 PM" + */ val dateTimeFormatter: DateTimeFormatter by lazy { - DateTimeFormat.forPattern("dd.MM.yyyy HH:mm") + getBestFormatterBySkeleton("dd.MM.yyyy HH:mm") } fun formatDate(date: DateTime, formatter: DateTimeFormatter = dateFormatter): String { return formatter.print(date) } + + /** + * Returns the best date and time format pattern for the given skeleton and the current locale. + * (In API version < 24, there may be some problems with getting the best date and time format pattern.) + * + * @param skeleton The skeleton is an alternative to the pattern. The difference is that the pattern rigidly + * defines the date/time format, while the skeleton specifies only the date/time components (year, month, day, etc.) + * So the order of the components and separators (space, comma, etc.) is not taken into account. + * @see [dateYYYY], [dateMMMdd], [dateDDMMYYYY], [dateTimeFormatter] + */ + fun getBestFormatterBySkeleton(skeleton: String): DateTimeFormatter { + val skeletonWithLocale = skeleton.replaceHourLetters() + + return DateTimeFormatterBuilder() + .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), skeletonWithLocale)) + .toFormatter() + .withLocale(Locale.getDefault()) + } + + private fun String.replaceHourLetters(): String { + return if (is12HourFormat) { + this.replace('H', 'h').replace('k', 'K') + } else { + this.replace('h', 'H').replace('K', 'k') + } + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt index bc2560f6e9..e01b50dfb3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt @@ -29,5 +29,12 @@ fun Long.toDateFormatWithTodayYesterday(formatter: DateTimeFormatter = DateTimeF * Returns formatted time according to [formatter]. */ fun Long.toTimeFormat(formatter: DateTimeFormatter = DateTimeFormatters.timeFormatter): String { + return formatAsDateTime(formatter) +} + +/** + * Returns formatted date-time according to [formatter]. + */ +fun Long.formatAsDateTime(formatter: DateTimeFormatter): String { return DateTimeFormatters.formatDate(date = DateTime(this, DateTimeZone.getDefault()), formatter = formatter) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/PreviewUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/PreviewUtils.kt new file mode 100644 index 0000000000..6cb1f784b9 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/PreviewUtils.kt @@ -0,0 +1,55 @@ +package com.tangem.core.ui.utils + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.coroutines.delay + +/** + * A container that shows a shimmer effect on top of the actual content. + * The shimmer effect is toggled every 2 seconds. + * Used for previewing components with shimmer effect and comparing their sizes with the actual content. + * + * If height is changing during the preview, it means that the actual content is not aligned with the shimmer effect. + * + * @param actualContent The actual content to be displayed. + * @param shimmerContent The shimmer effect to be displayed. + */ +@Composable +fun PreviewShimmerContainer(actualContent: @Composable () -> Unit, shimmerContent: @Composable () -> Unit) { + Column { + var height by remember { mutableIntStateOf(0) } + Row { + Text("height = $height") + } + SpacerH4() + + var shimmerVisible by remember { mutableStateOf(true) } + + TangemThemePreview { + Box( + Modifier.onGloballyPositioned { + height = it.size.height + }, + ) { + actualContent() + if (shimmerVisible) { + shimmerContent() + } + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(timeMillis = 2000) + shimmerVisible = !shimmerVisible + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt new file mode 100644 index 0000000000..3cef274d3a --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/ScrollUtils.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll + +fun Modifier.disableNestedScroll(): Modifier = nestedScroll(DisableParentConnection) + +private object DisableParentConnection : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + return available.copy(x = 0f) + } +} \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_blast_22.xml b/core/ui/src/main/res/drawable/ic_blast_22.xml new file mode 100644 index 0000000000..fea379b692 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_blast_22.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_cyber_22.xml b/core/ui/src/main/res/drawable/ic_cyber_22.xml new file mode 100644 index 0000000000..b622330ee9 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_cyber_22.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_filecoin_22.xml b/core/ui/src/main/res/drawable/ic_filecoin_22.xml new file mode 100644 index 0000000000..3c71f2b663 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_filecoin_22.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/img_blast_22.xml b/core/ui/src/main/res/drawable/img_blast_22.xml new file mode 100644 index 0000000000..515289963d --- /dev/null +++ b/core/ui/src/main/res/drawable/img_blast_22.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/img_cyber_22.xml b/core/ui/src/main/res/drawable/img_cyber_22.xml new file mode 100644 index 0000000000..e6af2f1807 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_cyber_22.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_filecoin_22.xml b/core/ui/src/main/res/drawable/img_filecoin_22.xml new file mode 100644 index 0000000000..aca11bbf42 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_filecoin_22.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_staking_banner.xml b/core/ui/src/main/res/drawable/img_staking_banner.xml new file mode 100644 index 0000000000..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/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt index 837e169c8b..3334a3c339 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/api/ApiResponseRaise.kt @@ -67,7 +67,7 @@ suspend inline fun safeApiCall( ): T = recover( block = { call(ApiResponseRaise(raise = this)) }, recover = { - Timber.w(it, "Unable to perform safe API call") + Timber.e(it, "Unable to perform safe API call") onError(it) }, ) \ No newline at end of file diff --git a/data/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..e850d1ed35 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( @@ -18,8 +19,6 @@ internal class DefaultMarketsTokenRepository( private val dispatcherProvider: CoroutineDispatcherProvider, ) : MarketsTokenRepository { - private val tokenListConverter = TokenMarketListConverter() - private fun createTokenMarketsFetcher(firstBatchSize: Int, nextBatchSize: Int) = LimitOffsetBatchFetcher( prefetchDistance = firstBatchSize, batchSize = nextBatchSize, @@ -41,9 +40,9 @@ internal class DefaultMarketsTokenRepository( interval = request.params.priceChangeInterval.toRequestParam(), order = request.params.order.toRequestParam(), search = searchText, - generalCoins = request.params.showUnder100kMarketCapTokens.not(), offset = request.offset, limit = request.limit, + timestamp = if (isFirstBatchFetching) null else requestTimeStamp.get(), ).getOrThrow() } @@ -57,13 +56,13 @@ internal class DefaultMarketsTokenRepository( } if (isFirstBatchFetching) { - requestTimeStamp.set(0) // TODO when backend is ready + requestTimeStamp.set(res.timestamp ?: 0) } val last = res.tokens.size < request.limit return BatchFetchResult.Success( - data = tokenListConverter.convert(res), + data = TokenMarketListConverter.convert(res), last = last, empty = res.tokens.isEmpty(), ) @@ -81,12 +80,53 @@ internal class DefaultMarketsTokenRepository( marketsApi = marketsApi, ) + val atomicInteger = AtomicInteger(0) + return BatchListSource( fetchDispatcher = dispatcherProvider.io, context = batchingContext, - generateNewKey = { it.size }, + generateNewKey = { atomicInteger.getAndIncrement() }, batchFetcher = createTokenMarketsFetcher(firstBatchSize = firstBatchSize, nextBatchSize = nextBatchSize), updateFetcher = tokenMarketsUpdateFetcher, ).toBatchFlow() } + + override suspend fun getChart( + fiatCurrencyCode: String, + interval: PriceChangeInterval, + tokenId: String, + ): TokenChart { + val response = marketsApi.getCoinChart( + currency = fiatCurrencyCode, + coinId = tokenId, + interval = interval.toRequestParam(), + ) + + return TokenChartConverter.convert(interval, response.getOrThrow()) + } + + override suspend fun getTokenInfo( + fiatCurrencyCode: String, + tokenId: String, + languageCode: String, + ): TokenMarketInfo { + val response = marketsApi.getCoinMarketData( + currency = fiatCurrencyCode, + coinId = tokenId, + language = languageCode, + ) + + return TokenMarketInfoConverter.convert(response.getOrThrow()) + } + + override suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String): TokenQuotes { + // TODO change method when backend is ready + val response = marketsApi.getCoinMarketData( + currency = fiatCurrencyCode, + coinId = tokenId, + language = "en", + ) + + return TokenMarketInfoConverter.convert(response.getOrThrow()).quotes + } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt index 625f90797d..d1db77b24e 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,8 +1,7 @@ package com.tangem.data.markets -import com.tangem.data.markets.converters.TokenListChartConverter import com.tangem.data.markets.converters.TokenMarketChartsConverter -import com.tangem.data.markets.converters.TokenQuotesConverter +import com.tangem.data.markets.converters.TokenQuotesShortConverter import com.tangem.data.markets.converters.toRequestParam import com.tangem.data.markets.utils.retryOnError import com.tangem.datasource.api.common.response.getOrThrow @@ -21,9 +20,6 @@ internal class MarketsBatchUpdateFetcher( private val tangemTechApi: TangemTechApi, ) : BatchUpdateFetcher, TokenMarketUpdateRequest> { - private val tokenListChartsConverter = TokenMarketChartsConverter(TokenListChartConverter()) - private val tokenQuotesConverter = TokenQuotesConverter() - override suspend fun BatchUpdateFetcher.UpdateContext>.fetchUpdateAsync( toUpdate: List>>, updateRequest: TokenMarketUpdateRequest, @@ -75,7 +71,7 @@ internal class MarketsBatchUpdateFetcher( val res = toUpdate.map { batch -> batch.copy( data = batch.data.map { - it.copy(tokenQuotes = tokenQuotesConverter.convert(it.id, quotesRes)) + it.copy(tokenQuotesShort = TokenQuotesShortConverter.convert(it.id, quotesRes)) }, ) } @@ -97,7 +93,7 @@ internal class MarketsBatchUpdateFetcher( key = batchToUpdate.key, data = batchToUpdate.data.map { it.copy( - tokenCharts = tokenListChartsConverter.convert( + tokenCharts = TokenMarketChartsConverter.convert( chartsToCopy = it.tokenCharts, tokenId = it.id, interval = updateRequest.interval, diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListChartConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenChartConverter.kt similarity index 83% rename from data/markets/src/main/java/com/tangem/data/markets/converters/TokenListChartConverter.kt rename to data/markets/src/main/java/com/tangem/data/markets/converters/TokenChartConverter.kt index 0b4cf678c6..744e6fef48 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListChartConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenChartConverter.kt @@ -4,13 +4,13 @@ import com.tangem.datasource.api.markets.models.response.TokenMarketChartRespons import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenChart -class TokenListChartConverter { +internal object TokenChartConverter { fun convert(interval: PriceChangeInterval, value: TokenMarketChartResponse): TokenChart { return TokenChart( interval = interval, priceY = value.prices.values.toList(), - timeStamp = value.prices.keys.toList(), + timeStamps = value.prices.keys.toList(), ) } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt index f47c0ff170..1875a5b609 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt @@ -3,13 +3,13 @@ package com.tangem.data.markets.converters import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarketListConfig -fun TokenMarketListConfig.Interval.toRequestParam(): String = when (this) { +internal fun TokenMarketListConfig.Interval.toRequestParam(): String = when (this) { TokenMarketListConfig.Interval.H24 -> "24h" TokenMarketListConfig.Interval.WEEK -> "1w" TokenMarketListConfig.Interval.MONTH -> "30d" } -fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { +internal fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { TokenMarketListConfig.Order.ByRating -> "rating" TokenMarketListConfig.Order.Trending -> "trending" TokenMarketListConfig.Order.Buyers -> "buyers" @@ -17,10 +17,10 @@ fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { TokenMarketListConfig.Order.TopLosers -> "losers" } -fun PriceChangeInterval.toRequestParam(): String = when (this) { +internal fun PriceChangeInterval.toRequestParam(): String = when (this) { PriceChangeInterval.H24 -> "24h" PriceChangeInterval.WEEK -> "1w" - PriceChangeInterval.MONTH -> "30d" + PriceChangeInterval.MONTH -> "1m" PriceChangeInterval.MONTH3 -> "3m" PriceChangeInterval.MONTH6 -> "6m" PriceChangeInterval.YEAR -> "1y" diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt index dcfdee4638..fdd44ffba9 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt @@ -3,31 +3,36 @@ package com.tangem.data.markets.converters import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarket +import com.tangem.domain.markets.TokenMarketListConfig -class TokenMarketChartsConverter( - private val tokenListChartConverter: TokenListChartConverter, -) { +internal object TokenMarketChartsConverter { fun convert( chartsToCopy: TokenMarket.Charts, tokenId: String, - interval: PriceChangeInterval, + interval: TokenMarketListConfig.Interval, value: TokenMarketChartListResponse, ): TokenMarket.Charts { val prices = requireNotNull(value[tokenId]) { "$tokenId is not found in the response. This shouldn't have happened." } return when (interval) { - PriceChangeInterval.H24 -> chartsToCopy.copy( - h24 = tokenListChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.H24 -> chartsToCopy.copy( + h24 = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) - PriceChangeInterval.WEEK -> chartsToCopy.copy( - week = tokenListChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.WEEK -> chartsToCopy.copy( + week = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) - PriceChangeInterval.MONTH -> chartsToCopy.copy( - month = tokenListChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.MONTH -> chartsToCopy.copy( + month = TokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) else -> error("unsupported interval=$interval. This shouldn't have happened.") } } + + private fun TokenMarketListConfig.Interval.toPriceChangeInterval(): PriceChangeInterval = when (this) { + TokenMarketListConfig.Interval.H24 -> PriceChangeInterval.H24 + TokenMarketListConfig.Interval.WEEK -> PriceChangeInterval.WEEK + TokenMarketListConfig.Interval.MONTH -> PriceChangeInterval.MONTH + } } \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt new file mode 100644 index 0000000000..e9314b0d7e --- /dev/null +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketInfoConverter.kt @@ -0,0 +1,116 @@ +package com.tangem.data.markets.converters + +import com.tangem.datasource.api.markets.models.response.TokenMarketInfoResponse +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenQuotes +import com.tangem.utils.converter.Converter + +internal object TokenMarketInfoConverter : Converter { + + override fun convert(value: TokenMarketInfoResponse): TokenMarketInfo { + return with(value) { + TokenMarketInfo( + id = id, + name = name, + symbol = symbol, + quotes = getQuotes(), + networks = networks?.convert(), + shortDescription = shortDescription, + fullDescription = fullDescription, + insights = insights?.convert(), + metrics = metrics?.convert(), + links = links?.convert(), + pricePerformance = pricePerformance?.convert(), + ) + } + } + + private fun TokenMarketInfoResponse.getQuotes(): TokenQuotes { + return TokenQuotes( + currentPrice = currentPrice, + h24ChangePercent = priceChangePercentage?.day?.movePointLeft(2), + weekChangePercent = priceChangePercentage?.week?.movePointLeft(2), + monthChangePercent = priceChangePercentage?.month?.movePointLeft(2), + m3ChangePercent = priceChangePercentage?.threeMonths?.movePointLeft(2), + m6ChangePercent = priceChangePercentage?.sixMonths?.movePointLeft(2), + yearChangePercent = priceChangePercentage?.year?.movePointLeft(2), + allTimeChangePercent = priceChangePercentage?.allTime?.movePointLeft(2), + ) + } + + @JvmName("convertNetwork") + private fun List.convert(): List { + return map { + TokenMarketInfo.Network( + networkId = it.networkId, + exchangeable = it.exchangeable, + contractAddress = it.contractAddress, + decimalCount = it.decimalCount, + ) + } + } + + @JvmName("convertInsight") + private fun TokenMarketInfoResponse.Insights.convert(): TokenMarketInfo.Insights { + return TokenMarketInfo.Insights( + holdersChange = holdersChange?.convert(), + liquidityChange = liquidityChange?.convert(), + buyPressureChange = buyPressureChange?.convert(), + experiencedBuyerChange = experiencedBuyerChange?.convert(), + ) + } + + private fun TokenMarketInfoResponse.Change.convert(): TokenMarketInfo.Change { + return TokenMarketInfo.Change( + day = day, + week = week, + month = month, + ) + } + + private fun TokenMarketInfoResponse.Metrics.convert(): TokenMarketInfo.Metrics { + return TokenMarketInfo.Metrics( + marketRating = marketRating, + circulatingSupply = circulatingSupply, + marketCap = marketCap, + volume24h = volume24h, + totalSupply = totalSupply, + fullyDilutedValuation = fullyDilutedValuation, + ) + } + + private fun TokenMarketInfoResponse.Links.convert(): TokenMarketInfo.Links { + return TokenMarketInfo.Links( + officialLinks = officialLinks?.convert(), + social = social?.convert(), + repository = repository?.convert(), + blockchainSite = blockchainSite?.convert(), + ) + } + + @JvmName("convertLink") + private fun List.convert(): List { + return map { + TokenMarketInfo.Link( + title = it.title, + id = it.id, + link = it.link, + ) + } + } + + private fun TokenMarketInfoResponse.PricePerformance.convert(): TokenMarketInfo.PricePerformance { + return TokenMarketInfo.PricePerformance( + day = day?.convert(), + month = month?.convert(), + allTime = allTime?.convert(), + ) + } + + private fun TokenMarketInfoResponse.Range.convert(): TokenMarketInfo.Range { + return TokenMarketInfo.Range( + low = low, + high = high, + ) + } +} \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index 96f7e01ebf..72aae03ec4 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -1,12 +1,11 @@ package com.tangem.data.markets.converters import com.tangem.datasource.api.markets.models.response.TokenMarketListResponse -import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarket -import com.tangem.domain.markets.TokenQuotes +import com.tangem.domain.markets.TokenQuotesShort import com.tangem.utils.converter.Converter -class TokenMarketListConverter : Converter> { +internal object TokenMarketListConverter : Converter> { override fun convert(value: TokenMarketListResponse): List { val imageHost = value.imageHost ?: run { @@ -25,13 +24,11 @@ class TokenMarketListConverter : Converter - val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) + scope.launch { + mutex.withLock { + appPreferencesStore.editData { preferences -> + val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) - preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs) + preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs) + } + } } } diff --git a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt index d1daa5b8a0..3efdd2e7eb 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt @@ -9,6 +9,7 @@ import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.PermissionRepository import com.tangem.domain.settings.repositories.PromoSettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -21,8 +22,11 @@ internal object SettingsDataModule { @Provides @Singleton - fun provideSettingsRepository(appPreferencesStore: AppPreferencesStore): SettingsRepository { - return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore) + fun provideSettingsRepository( + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): SettingsRepository { + return DefaultSettingsRepository(appPreferencesStore = appPreferencesStore, dispatchers = dispatchers) } @Provides diff --git a/data/staking/build.gradle.kts b/data/staking/build.gradle.kts index 0a1688dee1..017cc43973 100644 --- a/data/staking/build.gradle.kts +++ b/data/staking/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.staking) implementation(projects.domain.wallets.models) + implementation(projects.domain.legacy) /** Feature Api modules */ implementation(projects.features.staking.api) @@ -40,6 +41,8 @@ dependencies { implementation(projects.libs.blockchainSdk) + + implementation(deps.tangem.card.core) implementation(deps.tangem.blockchain) { exclude(module = "joda-time") } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index bc015d17b5..9ce502abe3 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -1,8 +1,14 @@ package com.tangem.data.staking +import android.util.Base64 import arrow.core.raise.catch import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionStatus +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toCompressedPublicKey import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.staking.converters.* import com.tangem.data.staking.converters.action.ActionStatusConverter @@ -23,7 +29,11 @@ import com.tangem.datasource.local.token.StakingBalanceStore import com.tangem.datasource.local.token.StakingYieldsStore import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.core.lce.lceFlow -import com.tangem.domain.staking.model.* +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata +import com.tangem.domain.staking.model.stakekit.NetworkType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList @@ -37,16 +47,17 @@ import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.toFormattedString +import com.tangem.utils.extensions.orZero import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@Suppress("LargeClass", "LongParameterList") +@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class DefaultStakingRepository( private val stakeKitApi: StakeKitApi, private val appPreferencesStore: AppPreferencesStore, @@ -55,6 +66,7 @@ internal class DefaultStakingRepository( private val cacheRegistry: CacheRegistry, private val dispatchers: CoroutineDispatcherProvider, private val stakingFeatureToggle: StakingFeatureToggles, + private val walletManagersFacade: WalletManagersFacade, ) : StakingRepository { private val stakingNetworkTypeConverter = StakingNetworkTypeConverter() @@ -92,8 +104,8 @@ internal class DefaultStakingRepository( value = emptyMap(), ) - override fun isStakingSupported(currencyId: String): Boolean { - return integrationIdMap.containsKey(currencyId) + override fun isStakingSupported(integrationKey: String): Boolean { + return integrationIdMap.containsKey(integrationKey) } override suspend fun fetchEnabledYields(refresh: Boolean) { @@ -129,7 +141,7 @@ internal class DefaultStakingRepository( val yield = getYield(cryptoCurrencyId, symbol) StakingEntryInfo( - interestRate = yield.apy, + interestRate = requireNotNull(yield.validators.maxByOrNull { it.apr.orZero() }?.apr), periodInDays = yield.metadata.cooldownPeriod.days, tokenSymbol = yield.token.symbol, ) @@ -146,7 +158,7 @@ internal class DefaultStakingRepository( val yields = getEnabledYields() ?: return@withContext StakingAvailability.Unavailable val prefetchedYield = findPrefetchedYield(yields, rawCurrencyId, symbol) - val isSupported = isStakingSupported(rawCurrencyId) + val isSupported = isStakingSupported(cryptoCurrencyId.getIntegrationKey()) when { prefetchedYield != null && isSupported -> { @@ -160,12 +172,30 @@ internal class DefaultStakingRepository( } } - override suspend fun createAction(params: ActionParams): StakingAction { + override suspend fun createAction( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingAction { return withContext(dispatchers.io) { val response = when (params.actionCommonType) { - StakingActionCommonType.ENTER -> stakeKitApi.createEnterAction(createActionRequestBody(params)) - StakingActionCommonType.EXIT -> stakeKitApi.createExitAction(createActionRequestBody(params)) - StakingActionCommonType.PENDING -> stakeKitApi.createPendingAction( + StakingActionCommonType.ENTER -> stakeKitApi.createEnterAction( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.EXIT -> stakeKitApi.createExitAction( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.PENDING_OTHER, + StakingActionCommonType.PENDING_REWARDS, + -> stakeKitApi.createPendingAction( createPendingActionRequestBody(params), ) } @@ -174,12 +204,30 @@ internal class DefaultStakingRepository( } } - override suspend fun estimateGas(params: ActionParams): StakingGasEstimate { + override suspend fun estimateGas( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingGasEstimate { return withContext(dispatchers.io) { val gasEstimateDTO = when (params.actionCommonType) { - StakingActionCommonType.ENTER -> stakeKitApi.estimateGasOnEnter(createActionRequestBody(params)) - StakingActionCommonType.EXIT -> stakeKitApi.estimateGasOnExit(createActionRequestBody(params)) - StakingActionCommonType.PENDING -> stakeKitApi.estimateGasOnPending( + StakingActionCommonType.ENTER -> stakeKitApi.estimateGasOnEnter( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.EXIT -> stakeKitApi.estimateGasOnExit( + createActionRequestBody( + userWalletId, + network, + params, + ), + ) + StakingActionCommonType.PENDING_REWARDS, + StakingActionCommonType.PENDING_OTHER, + -> stakeKitApi.estimateGasOnPending( createPendingActionRequestBody(params), ) } @@ -188,14 +236,26 @@ internal class DefaultStakingRepository( } } - override suspend fun constructTransaction(transactionId: String): StakingTransaction { + override suspend fun constructTransaction( + networkId: String, + fee: Fee, + transactionId: String, + ): Pair { return withContext(dispatchers.io) { val transactionResponse = stakeKitApi.constructTransaction( transactionId = transactionId, body = ConstructTransactionRequestBody(), ) - transactionConverter.convert(transactionResponse.getOrThrow()) + val transaction = transactionConverter.convert(transactionResponse.getOrThrow()) + val unsignedTransaction = transaction.unsignedTransaction ?: error("No unsigned transaction available") + val transactionData = TransactionData.Compiled( + value = getTransactionDataType(networkId, unsignedTransaction), + fee = fee, + status = TransactionStatus.Unconfirmed, + ) + + transaction to transactionData } } @@ -207,10 +267,7 @@ internal class DefaultStakingRepository( if (!stakingFeatureToggle.isStakingEnabled) return@withContext val cryptoCurrency = address.cryptoCurrency - val rawCurrencyId = - cryptoCurrency.id.rawCurrencyId ?: error("Staking custom tokens is not available") - - val integrationId = integrationIdMap[rawCurrencyId] ?: return@withContext + val integrationId = integrationIdMap[cryptoCurrency.id.getIntegrationKey()] ?: return@withContext cacheRegistry.invokeOnExpire( key = getYieldBalancesKey(userWalletId), @@ -241,7 +298,7 @@ internal class DefaultStakingRepository( send(YieldBalance.Empty) } else { launch(dispatchers.io) { - val integrationId = integrationIdMap[address.cryptoCurrency.id.rawCurrencyId] + val integrationId = integrationIdMap[address.cryptoCurrency.id.getIntegrationKey()] ?: error("Could not get integrationId") stakingBalanceStore.get(integrationId) .collectLatest { @@ -274,7 +331,7 @@ internal class DefaultStakingRepository( } else { fetchSingleYieldBalance(userWalletId, address) - val integrationId = integrationIdMap[address.cryptoCurrency.id.rawCurrencyId] + val integrationId = integrationIdMap[address.cryptoCurrency.id.getIntegrationKey()] ?: error("Could not get integrationId") val result = stakingBalanceStore.getSyncOrNull(integrationId) ?: return@withContext YieldBalance.Error yieldBalanceConverter.convert( @@ -304,8 +361,7 @@ internal class DefaultStakingRepository( addresses .mapNotNull { networkAddress -> val cryptoCurrency = networkAddress.cryptoCurrency - val rawCurrencyId = cryptoCurrency.id.rawCurrencyId ?: error("Currency raw id is null") - val integrationId = integrationIdMap[rawCurrencyId] + val integrationId = integrationIdMap[cryptoCurrency.id.getIntegrationKey()] if (integrationId != null) { networkAddress.address to integrationId @@ -435,12 +491,19 @@ internal class DefaultStakingRepository( } } - private fun createActionRequestBody(params: ActionParams): ActionRequestBody { + private suspend fun createActionRequestBody( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): ActionRequestBody { return ActionRequestBody( integrationId = params.integrationId, - addresses = Address(params.address), + addresses = Address( + address = params.address, + additionalAddresses = createAdditionalAddresses(userWalletId, network, params), + ), args = ActionRequestBodyArgs( - amount = params.amount.toFormattedString(params.token.decimals), + amount = params.amount.toPlainString(), inputToken = tokenConverter.convertBack(params.token), validatorAddress = params.validatorAddress, ), @@ -453,12 +516,29 @@ internal class DefaultStakingRepository( type = params.type ?: StakingActionType.UNKNOWN, passthrough = params.passthrough.orEmpty(), args = ActionRequestBodyArgs( - amount = params.amount.toFormattedString(params.token.decimals), + amount = params.amount.toPlainString(), validatorAddress = params.validatorAddress, ), ) } + private suspend fun createAdditionalAddresses( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): Address.AdditionalAddresses? { + val selectedWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + return when (params.token.network) { + NetworkType.COSMOS -> Address.AdditionalAddresses( + cosmosPubKey = Base64.encodeToString( + /* input = */ selectedWallet?.wallet?.publicKey?.blockchainKey?.toCompressedPublicKey(), + /* flags = */ Base64.NO_WRAP, + ), + ) + else -> null + } + } + override fun isStakeMoreAvailable(networkId: Network.ID): Boolean { val blockchain = Blockchain.fromId(networkId.value) return when (blockchain) { @@ -467,6 +547,27 @@ internal class DefaultStakingRepository( } } + override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval { + return when (cryptoCurrency.id.getIntegrationKey()) { + Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() -> { + StakingApproval.Needed(ETHEREUM_POLYGON_APPROVE_SPENDER) + } + else -> StakingApproval.Empty + } + } + + private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data { + val blockchain = Blockchain.fromId(networkId) + return when (blockchain) { + Blockchain.Solana, + Blockchain.Cosmos, + -> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes()) + Blockchain.Ethereum, + -> TransactionData.Compiled.Data.RawString(unsignedTransaction) + else -> error("Unsupported blockchain") + } + } + private fun findPrefetchedYield(yields: List, currencyId: String, symbol: String): Yield? { return yields.find { it.token.coinGeckoId == currencyId && it.token.symbol == symbol } } @@ -492,33 +593,38 @@ internal class DefaultStakingRepository( private fun getYieldBalancesKey(userWalletId: UserWalletId) = "yield_balance_${userWalletId.stringValue}" + private fun CryptoCurrency.ID.getIntegrationKey(): String = rawNetworkId.plus(rawCurrencyId) + private companion object { const val YIELDS_STORE_KEY = "yields" const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" + const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" const val POLKADOT_INTEGRATION_ID = "polkadot-dot-validator-staking" - const val ETHEREUM_INTEGRATION_ID = "ethereum-matic-native-staking" const val AVALANCHE_INTEGRATION_ID = "avalanche-avax-native-staking" const val TRON_INTEGRATION_ID = "tron-trx-native-staking" const val CRONOS_INTEGRATION_ID = "cronos-cro-native-staking" - const val BINANCE_INTEGRATION_ID = "binance-bnb-native-staking" + const val BINANCE_INTEGRATION_ID = "bsc-bnb-native-staking" const val KAVA_INTEGRATION_ID = "kava-kava-native-staking" const val NEAR_INTEGRATION_ID = "near-near-native-staking" const val TEZOS_INTEGRATION_ID = "tezos-xtz-native-staking" + const val ETHEREUM_POLYGON_APPROVE_SPENDER = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + + // uncomment items as implementation is ready val integrationIdMap = mapOf( - Blockchain.Solana.toCoinId() to SOLANA_INTEGRATION_ID, - Blockchain.Cosmos.toCoinId() to COSMOS_INTEGRATION_ID, - Blockchain.Polkadot.toCoinId() to POLKADOT_INTEGRATION_ID, - Blockchain.Polygon.toCoinId() to ETHEREUM_INTEGRATION_ID, - Blockchain.Avalanche.toCoinId() to AVALANCHE_INTEGRATION_ID, - Blockchain.Tron.toCoinId() to TRON_INTEGRATION_ID, - Blockchain.Cronos.toCoinId() to CRONOS_INTEGRATION_ID, - Blockchain.Binance.toCoinId() to BINANCE_INTEGRATION_ID, - Blockchain.Kava.toCoinId() to KAVA_INTEGRATION_ID, - Blockchain.Near.toCoinId() to NEAR_INTEGRATION_ID, - Blockchain.Tezos.toCoinId() to TEZOS_INTEGRATION_ID, + Blockchain.Solana.run { id + toCoinId() } to SOLANA_INTEGRATION_ID, + Blockchain.Cosmos.run { id + toCoinId() } to COSMOS_INTEGRATION_ID, + Blockchain.Ethereum.id + Blockchain.Polygon.toCoinId() to ETHEREUM_POLYGON_INTEGRATION_ID, + // Blockchain.Polkadot.run { id + toCoinId() } to POLKADOT_INTEGRATION_ID, + // Blockchain.Avalanche.run { id + toCoinId() } to AVALANCHE_INTEGRATION_ID, + // Blockchain.Tron.run { id + toCoinId() } to TRON_INTEGRATION_ID, + // Blockchain.Cronos.run { id + toCoinId() } to CRONOS_INTEGRATION_ID, + // Blockchain.BSC.run { id + toCoinId() } to BINANCE_INTEGRATION_ID, + // Blockchain.Kava.run { id + toCoinId() } to KAVA_INTEGRATION_ID, + // Blockchain.Near.run { id + toCoinId() } to NEAR_INTEGRATION_ID, + // Blockchain.Tezos.run { id + toCoinId() } to TEZOS_INTEGRATION_ID, ) } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt index c46a15f279..b1c9d7d47c 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/converters/YieldBalanceConverter.kt @@ -24,6 +24,7 @@ internal class YieldBalanceConverter : Converter Yield.RewardType.UNKNOWN } } + + private fun convertRewardSchedule(rewardTypeDTO: RewardScheduleDTO): RewardSchedule { + return when (rewardTypeDTO) { + RewardScheduleDTO.BLOCK -> RewardSchedule.BLOCK + RewardScheduleDTO.WEEK -> RewardSchedule.WEEK + RewardScheduleDTO.HOUR -> RewardSchedule.HOUR + RewardScheduleDTO.DAY -> RewardSchedule.DAY + RewardScheduleDTO.MONTH -> RewardSchedule.MONTH + RewardScheduleDTO.ERA -> RewardSchedule.ERA + RewardScheduleDTO.EPOCH -> RewardSchedule.EPOCH + else -> RewardSchedule.UNKNOWN + } + } + + private fun convertRewardClaiming( + rewardClaimingDTO: YieldDTO.MetadataDTO.RewardClaimingDTO, + ): Yield.Metadata.RewardClaiming { + return when (rewardClaimingDTO) { + YieldDTO.MetadataDTO.RewardClaimingDTO.AUTO -> Yield.Metadata.RewardClaiming.AUTO + YieldDTO.MetadataDTO.RewardClaimingDTO.MANUAL -> Yield.Metadata.RewardClaiming.MANUAL + else -> Yield.Metadata.RewardClaiming.UNKNOWN + } + } + + private fun convertArgType(value: String): Yield.Args.ArgType { + return when (value) { + "address" -> Yield.Args.ArgType.ADDRESS + "amount" -> Yield.Args.ArgType.AMOUNT + else -> Yield.Args.ArgType.UNKNOWN + } + } } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index f0562851c1..d261cdece4 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -13,6 +13,7 @@ import com.tangem.datasource.local.token.StakingBalanceStore import com.tangem.datasource.local.token.StakingYieldsStore import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.features.staking.api.featuretoggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -35,6 +36,7 @@ internal object StakingDataModule { dispatchers: CoroutineDispatcherProvider, stakingFeatureToggle: StakingFeatureToggles, cacheRegistry: CacheRegistry, + walletManagersFacade: WalletManagersFacade, ): StakingRepository { return DefaultStakingRepository( stakeKitApi = stakeKitApi, @@ -44,6 +46,7 @@ internal object StakingDataModule { dispatchers = dispatchers, cacheRegistry = cacheRegistry, stakingFeatureToggle = stakingFeatureToggle, + walletManagersFacade = walletManagersFacade, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/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/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt index 819dbe2a39..2a3ff567e6 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt @@ -56,6 +56,8 @@ internal class QuotesUnsupportedCurrenciesIdAdapter { "polygon-zkevm-ethereum" to "ethereum", "aurora-ethereum" to "ethereum", "base-ethereum" to "ethereum", + "cyberconnect" to "ethereum", + "blast" to "ethereum", ) } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt index 4cc79ed407..0ad1df2428 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt @@ -106,6 +106,7 @@ class ResponseCryptoCurrenciesFactory { Blockchain.Telos, Blockchain.Cronos, Blockchain.TON, + Blockchain.Cyber, -> this.fullName else -> responseToken.name } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 56922c5bf0..87f16d07be 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -16,6 +16,7 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.extensions.hexToBytes import com.tangem.datasource.local.walletmanager.WalletManagersStore +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.models.TransactionType @@ -24,6 +25,7 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext import timber.log.Timber +import java.math.BigDecimal import java.math.BigInteger import com.tangem.blockchain.blockchains.tron.TransactionType as SdkTransactionType @@ -42,15 +44,15 @@ internal class DefaultTransactionRepository( network: Network, txExtras: TransactionExtras?, hash: String?, - ): TransactionData.Uncompiled? = withContext(coroutineDispatcherProvider.io) { + ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) val walletManager = walletManagersFacade.getOrCreateWalletManager( userWalletId = userWalletId, blockchain = blockchain, derivationPath = network.derivationPath.value, - ) + ) ?: error("Wallet manager not found") - return@withContext walletManager?.createTransactionDataInternal( + return@withContext walletManager.createTransactionDataInternal( amount = amount, fee = fee, memo = memo, @@ -61,6 +63,48 @@ internal class DefaultTransactionRepository( ) } + override suspend fun createApprovalTransaction( + amount: Amount, + fee: Fee, + contractAddress: String, + spenderAddress: String, + userWalletId: UserWalletId, + network: Network, + hash: String?, + ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) ?: error("Wallet manager not found") + val approver = walletManager as? Approver ?: error("Cannot cast to Approver") + + val approvalData = approver.getApproveData( + spenderAddress = spenderAddress, + value = amount, + ) + + val extras = createTransactionDataExtras( + data = approvalData, + network = network, + transactionType = TransactionType.APPROVE, + nonce = null, + gasLimit = null, + ) + + return@withContext createTransaction( + amount = amount, + fee = fee, + memo = null, + destination = contractAddress, + userWalletId = userWalletId, + network = network, + txExtras = extras, + hash = hash, + ) + } + override suspend fun validateTransaction( amount: Amount, fee: Fee?, @@ -141,6 +185,28 @@ internal class DefaultTransactionRepository( } } + override suspend fun getAllowance( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency.Token, + spenderAddress: String, + ): BigDecimal { + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, cryptoCurrency.network) + val blockchain = Blockchain.fromId(cryptoCurrency.network.id.value) + val allowanceResult = (walletManager as? Approver)?.getAllowance( + spenderAddress, + Token( + symbol = blockchain.currency, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) ?: error("Cannot cast to Approver") + + return allowanceResult.fold( + onSuccess = { it }, + onFailure = { error(it) }, + ) + } + private fun convertToSdkTransactionType(transactionType: TransactionType): SdkTransactionType { return when (transactionType) { TransactionType.APPROVE -> SdkTransactionType.APPROVE diff --git a/domain/app-currency/models/build.gradle.kts b/domain/app-currency/models/build.gradle.kts index 7ff7fb7522..6b18f3f83f 100644 --- a/domain/app-currency/models/build.gradle.kts +++ b/domain/app-currency/models/build.gradle.kts @@ -1,4 +1,9 @@ plugins { alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) id("configuration") +} + +dependencies { + implementation(deps.kotlin.serialization) } \ No newline at end of file diff --git a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt index 9d5261347a..adadcdcbac 100644 --- a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt +++ b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt @@ -1,5 +1,8 @@ package com.tangem.domain.appcurrency.model +import kotlinx.serialization.Serializable + +@Serializable data class AppCurrency( val code: String, val name: String, diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt index 4f107a0948..5990fe9c1a 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/GetExtendedPublicKeyForCurrencyUseCase.kt @@ -1,30 +1,140 @@ package com.tangem.domain.card import arrow.core.Either +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.extensions.calculateRipemd160 +import com.tangem.common.extensions.calculateSha256 import com.tangem.crypto.NetworkType import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.operations.derivation.ExtendedPublicKeysMap /** * Derivates an exteneded public key (xpub) based on blockchain hardened derivation */ class GetExtendedPublicKeyForCurrencyUseCase( private val derivationsRepository: DerivationsRepository, + private val walletManagersFacade: WalletManagersFacade, ) { - suspend operator fun invoke( - userWalletId: UserWalletId, - derivation: Network.DerivationPath, - ): Either { + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either { return Either.catch { - val derivationPath = requireNotNull(derivation.value?.let { DerivationPath(it) }) { - error("Derivation is null") + val userWallet = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) + ?: error("Wallet not found") + + val blockchain = Blockchain.fromId(network.id.value) + val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain) + + val hdKey = if (isSecp256k1Blockchain) { + userWallet.wallet.publicKey.derivationType?.hdKey ?: error("No derivation found") + } else { + error("No derivation found") } - val hardenedNodes = derivationPath.nodes.filter { it.isHardened } - val hardenedDerivation = DerivationPath(hardenedNodes) - derivationsRepository.deriveExtendedPublicKey(userWalletId, hardenedDerivation) - ?.serialize(NetworkType.Mainnet).orEmpty() + + var childKey = makeChildKey( + isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(), + extendedPublicKey = hdKey.extendedPublicKey, + derivationPath = hdKey.path, + ) + + var parentKey = Key( + derivationPath = childKey.derivationPath.dropLastNodes(1), + extendedPublicKey = null, + ) + + val pendingDerivations = getPendingDerivations(childKey, parentKey) + val derivedKeys = deriveKeys( + userWalletId = userWalletId, + seedKey = userWallet.wallet.publicKey.seedKey, + paths = pendingDerivations, + ) + + if (childKey.extendedPublicKey == null) { + childKey = childKey.copy( + extendedPublicKey = derivedKeys[childKey.derivationPath] ?: error("Failed to derive child key"), + ) + } + + if (parentKey.extendedPublicKey == null) { + parentKey = parentKey.copy( + extendedPublicKey = derivedKeys[parentKey.derivationPath] ?: error("Failed to derive parent key"), + ) + } + + makeExtendedKey(childKey, parentKey, network.isTestnet) } } + + private suspend fun deriveKeys( + userWalletId: UserWalletId, + seedKey: ByteArray, + paths: MutableList, + ): ExtendedPublicKeysMap { + val result = derivationsRepository.derivePublicKeys(userWalletId, mapOf(ByteArrayKey(seedKey) to paths)) + return result.getValue(ByteArrayKey(seedKey)) + } + + private fun makeExtendedKey(childKey: Key, parentKey: Key, isTestnet: Boolean): String { + val publicKey = childKey.extendedPublicKey?.publicKey ?: error("No public key found") + val chainCode = childKey.extendedPublicKey.chainCode + val lastChildNode = childKey.derivationPath.nodes.last() + val parentPublicKey = parentKey.extendedPublicKey?.publicKey + + val depth = childKey.derivationPath.nodes.size + val childNumber = lastChildNode.index + val parentFingerprint = parentPublicKey + ?.calculateSha256()?.calculateRipemd160() + ?.take(PARENT_FINGERPRINT_SIZE)?.toByteArray() + ?: error("No parent fingerprint found") + + val net = if (isTestnet) NetworkType.Testnet else NetworkType.Mainnet + return ExtendedPublicKey( + publicKey = publicKey, + chainCode = chainCode, + depth = depth, + parentFingerprint = parentFingerprint, + childNumber = childNumber, + ).serialize(net) + } + + private fun getPendingDerivations(childKey: Key, parentKey: Key): MutableList { + val pendingDerivations = mutableListOf() + + if (childKey.extendedPublicKey == null) { + pendingDerivations.add(childKey.derivationPath) + } + + if (parentKey.extendedPublicKey == null) { + pendingDerivations.add(parentKey.derivationPath) + } + + return pendingDerivations + } + + private fun makeChildKey( + isBip44DerivationStyleXPUB: Boolean, + extendedPublicKey: ExtendedPublicKey, + derivationPath: DerivationPath, + ): Key = if (isBip44DerivationStyleXPUB) { + Key(derivationPath.dropLastNodes(2), null) + } else { + Key(derivationPath, extendedPublicKey) + } + + private fun DerivationPath.dropLastNodes(count: Int): DerivationPath { + return DerivationPath(nodes.dropLast(count)) + } + + private data class Key( + val derivationPath: DerivationPath, + val extendedPublicKey: ExtendedPublicKey?, + ) + + private companion object { + const val PARENT_FINGERPRINT_SIZE = 4 + } } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt index 209b68817a..d6b474839a 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/DerivationsRepository.kt @@ -1,9 +1,10 @@ package com.tangem.domain.card.repository +import com.tangem.common.extensions.ByteArrayKey import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.operations.derivation.ExtendedPublicKeysMap interface DerivationsRepository { @@ -11,5 +12,8 @@ interface DerivationsRepository { suspend fun derivePublicKeys(userWalletId: UserWalletId, currencies: List) @Throws - suspend fun deriveExtendedPublicKey(userWalletId: UserWalletId, derivation: DerivationPath): ExtendedPublicKey? + suspend fun derivePublicKeys( + userWalletId: UserWalletId, + derivations: Map>, + ): Map } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index a671bad033..a3cbd7d4f8 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -16,7 +16,8 @@ internal class FeedbackDataBuilder { fun addCardInfo(cardInfo: CardInfo) { builder.appendKeyValue("Card ID", cardInfo.cardId) builder.appendKeyValue("Firmware version", cardInfo.firmwareVersion) - builder.appendKeyValue("Imported wallet", if (cardInfo.isImported) "yes" else "no") + builder.appendKeyValue("Linked cards count", cardInfo.cardsCount) + builder.appendKeyValue("Has seed phrase", cardInfo.isImported.toString()) builder.appendKeyValue("Card Blockchain", cardInfo.cardBlockchain) builder.appendSignedHashes(cardInfo.signedHashesList) } @@ -34,12 +35,14 @@ internal class FeedbackDataBuilder { builder.appendKeyValue("Outputs count", outputsCount) if (tokens.isNotEmpty()) { - builder.append("Tokens:") builder.breakLine() tokens.forEach { token -> - builder.appendKeyValue("ID", token.id ?: "[custom token]") + builder.appendKeyValue("Token ID", token.id ?: "[custom token]") builder.appendKeyValue("Name", token.name) builder.appendKeyValue("Contract address", token.contractAddress) + builder.appendKeyValue("Decimals", token.decimals) + + builder.breakLine() } } diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt index da0a0df2a5..a3665f64d2 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/BlockchainInfo.kt @@ -24,5 +24,6 @@ data class BlockchainInfo( val id: String?, val name: String, val contractAddress: String, + val decimals: String, ) } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt index 2e7e710cc1..681cb957c0 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt @@ -6,6 +6,7 @@ data class CardInfo( val userWalletId: UserWalletId?, val cardId: String, val firmwareVersion: String, + val cardsCount: String, val cardBlockchain: String?, val signedHashesList: List, val isImported: Boolean, diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 5529922be2..885998c263 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -11,10 +11,12 @@ android { dependencies { - api(projects.domain.markets.models) + api(projects.domain.appCurrency.models) api(projects.domain.core) api(projects.core.pagination) + api(projects.domain.markets.models) implementation(deps.kotlin.serialization) implementation(projects.domain.tokens.models) + implementation(projects.core.utils) } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt index d239ae395a..4e23077cf1 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenChart.kt @@ -5,9 +5,9 @@ import java.math.BigDecimal data class TokenChart( val interval: PriceChangeInterval, val priceY: List, - val timeStamp: List, + val timeStamps: List, ) { init { - require(priceY.size == timeStamp.size) + require(priceY.size == timeStamps.size) } } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt index b7316cf79a..ac425d8e8a 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarket.kt @@ -8,7 +8,7 @@ data class TokenMarket( val symbol: String, val marketRating: Int?, val marketCap: BigDecimal?, - val tokenQuotes: TokenQuotes, + val tokenQuotesShort: TokenQuotesShort, val tokenCharts: Charts, private val imageHost: String, ) { diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt new file mode 100644 index 0000000000..7fe496e5f1 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketInfo.kt @@ -0,0 +1,70 @@ +package com.tangem.domain.markets + +import java.math.BigDecimal + +data class TokenMarketInfo( + val id: String, + val name: String, + val symbol: String, + val quotes: TokenQuotes, + val networks: List?, + val shortDescription: String?, + val fullDescription: String?, + val insights: Insights?, + val metrics: Metrics?, + val links: Links?, + val pricePerformance: PricePerformance?, +) { + data class Network( + val networkId: String, + val exchangeable: Boolean, + val contractAddress: String?, + val decimalCount: Int?, + ) + + data class Insights( + val holdersChange: Change?, + val liquidityChange: Change?, + val buyPressureChange: Change?, + val experiencedBuyerChange: Change?, + ) + + data class Change( + val day: BigDecimal?, + val week: BigDecimal?, + val month: BigDecimal?, + ) + + data class Metrics( + val marketRating: Int?, + val circulatingSupply: BigDecimal?, + val marketCap: BigDecimal?, + val volume24h: BigDecimal?, + val totalSupply: BigDecimal?, + val fullyDilutedValuation: BigDecimal?, + ) + + data class Links( + val officialLinks: List?, + val social: List?, + val repository: List?, + val blockchainSite: List?, + ) + + data class Link( + val title: String, + val id: String?, + val link: String, + ) + + data class PricePerformance( + val day: Range?, + val month: Range?, + val allTime: Range?, + ) + + data class Range( + val low: BigDecimal?, + val high: BigDecimal?, + ) +} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt index ad952091b6..c3633f3925 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketListConfig.kt @@ -3,7 +3,6 @@ package com.tangem.domain.markets data class TokenMarketListConfig( val fiatPriceCurrency: String, val searchText: String?, - val showUnder100kMarketCapTokens: Boolean, val priceChangeInterval: Interval, val order: Order, ) { diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt index bcac52fddb..a23a9136ba 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt @@ -7,7 +7,7 @@ sealed class TokenMarketUpdateRequest { ) : TokenMarketUpdateRequest() data class UpdateChart( - val interval: PriceChangeInterval, + val interval: TokenMarketListConfig.Interval, val currency: String, ) : TokenMarketUpdateRequest() } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt index 7a162c9aac..18826801e3 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotes.kt @@ -4,15 +4,11 @@ import java.math.BigDecimal data class TokenQuotes( val currentPrice: BigDecimal, - private val priceChanges: Map, -) { - init { - require(priceChanges.containsKey(PriceChangeInterval.H24)) - require(priceChanges.containsKey(PriceChangeInterval.WEEK)) - require(priceChanges.containsKey(PriceChangeInterval.MONTH)) - } - - fun h24Percent() = priceChanges[PriceChangeInterval.H24]!! - fun weekPercent() = priceChanges[PriceChangeInterval.WEEK]!! - fun monthPercent() = priceChanges[PriceChangeInterval.MONTH]!! -} \ No newline at end of file + val h24ChangePercent: BigDecimal?, + val weekChangePercent: BigDecimal?, + val monthChangePercent: BigDecimal?, + val m3ChangePercent: BigDecimal?, + val m6ChangePercent: BigDecimal?, + val yearChangePercent: BigDecimal?, + val allTimeChangePercent: BigDecimal?, +) \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt new file mode 100644 index 0000000000..3b6bb3bdf1 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.markets + +import java.math.BigDecimal + +data class TokenQuotesShort( + val currentPrice: BigDecimal, + val h24ChangePercent: BigDecimal, + val weekChangePercent: BigDecimal, + val monthChangePercent: BigDecimal, +) \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt index 8ba13797a3..c182f7bd43 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetMarketsTokenListFlowUseCase.kt @@ -16,6 +16,7 @@ class GetMarketsTokenListFlowUseCase( firstBatchSize = batchFlowType.firstBatchSize, nextBatchSize = batchFlowType.nextBatchSize, ) + // TODO listen quotes updates flow and update them in other parts of the application } enum class BatchFlowType( diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt new file mode 100644 index 0000000000..c7935cb4e0 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketInfoUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.repositories.MarketsTokenRepository +import com.tangem.utils.SupportedLanguages + +class GetTokenMarketInfoUseCase( + private val marketsTokenRepository: MarketsTokenRepository, +) { + + suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { + return Either.catch { + marketsTokenRepository.getTokenInfo( + fiatCurrencyCode = appCurrency.code, + tokenId = tokenId, + languageCode = SupportedLanguages.getCurrentSupportedLanguageCode(), + ) + }.mapLeft {} + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt new file mode 100644 index 0000000000..c686ff3874 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.repositories.MarketsTokenRepository + +class GetTokenPriceChartUseCase( + private val marketsTokenRepository: MarketsTokenRepository, +) { + + suspend operator fun invoke( + appCurrency: AppCurrency, + interval: PriceChangeInterval, + tokenId: String, + ): Either { + return Either.catch { + marketsTokenRepository.getChart( + fiatCurrencyCode = appCurrency.code, + interval = interval, + tokenId = tokenId, + ) + }.mapLeft {} + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt new file mode 100644 index 0000000000..6477010d57 --- /dev/null +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenQuotesUseCase.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.markets + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.repositories.MarketsTokenRepository + +class GetTokenQuotesUseCase( + private val marketsTokenRepository: MarketsTokenRepository, +) { + + suspend operator fun invoke(appCurrency: AppCurrency, tokenId: String): Either { + return Either.catch { + marketsTokenRepository.getTokenQuotes( + fiatCurrencyCode = appCurrency.code, + tokenId = tokenId, + ) + }.mapLeft {} + } +} \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index 31693705dc..5c80144aec 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -9,4 +9,10 @@ interface MarketsTokenRepository { firstBatchSize: Int, nextBatchSize: Int, ): TokenListBatchFlow + + suspend fun getChart(fiatCurrencyCode: String, interval: PriceChangeInterval, tokenId: String): TokenChart + + suspend fun getTokenInfo(fiatCurrencyCode: String, tokenId: String, languageCode: String): TokenMarketInfo + + suspend fun getTokenQuotes(fiatCurrencyCode: String, tokenId: String): TokenQuotes } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldSaveAccessCodesUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldSaveAccessCodesUseCase.kt new file mode 100644 index 0000000000..b68ede4106 --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldSaveAccessCodesUseCase.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.settings + +import com.tangem.domain.settings.repositories.SettingsRepository + +class ShouldSaveAccessCodesUseCase(private val settingsRepository: SettingsRepository) { + + suspend operator fun invoke(): Boolean = settingsRepository.shouldSaveAccessCodes() +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index 3b5f6d3c40..992990fe85 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -11,7 +11,7 @@ interface SettingsRepository { suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) @Throws - suspend fun updateAppLogs(message: String) + fun saveLogMessage(message: String) @Throws suspend fun deleteDeprecatedLogs(maxSize: Int) diff --git a/domain/staking/build.gradle.kts b/domain/staking/build.gradle.kts index e92bc23b4c..dd1083b53b 100644 --- a/domain/staking/build.gradle.kts +++ b/domain/staking/build.gradle.kts @@ -9,7 +9,6 @@ android { namespace = "com.tangem.domain.staking" } - dependencies { api(projects.domain.staking.models) @@ -20,4 +19,8 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.features.staking.api) + + implementation(deps.tangem.blockchain) { + exclude(module = "joda-time") + } } \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingApproval.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingApproval.kt new file mode 100644 index 0000000000..52056c1f11 --- /dev/null +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/StakingApproval.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.staking.model + +sealed class StakingApproval { + + data class Needed(val spenderAddress: String) : StakingApproval() + + data object Empty : StakingApproval() +} \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt index e173689c6f..f8f6b7732d 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/Yield.kt @@ -33,15 +33,21 @@ data class Yield( @Serializable data class Enter( val addresses: Addresses, - val args: Map, + val args: Map, ) { @Serializable data class Addresses( val address: AddressArgument, - val additionalAddresses: Map? = null, + val additionalAddresses: Map? = null, ) } + + enum class ArgType { + ADDRESS, + AMOUNT, + UNKNOWN, + } } @Serializable @@ -68,10 +74,10 @@ data class Yield( val token: Token, val tokens: List, val type: String, - val rewardSchedule: String, + val rewardSchedule: RewardSchedule, val cooldownPeriod: Period, val warmupPeriod: Period, - val rewardClaiming: String, + val rewardClaiming: RewardClaiming, val defaultValidator: String?, val minimumStake: Int?, val supportsMultipleValidators: Boolean, @@ -88,6 +94,25 @@ data class Yield( data class Enabled( val enabled: Boolean, ) + + enum class RewardSchedule { + BLOCK, + WEEK, + HOUR, + DAY, + MONTH, + ERA, + EPOCH, + + UNKNOWN, + } + + enum class RewardClaiming { + AUTO, + MANUAL, + + UNKNOWN, + } } enum class RewardType { @@ -113,6 +138,6 @@ data class Token( data class AddressArgument( val required: Boolean, val network: String? = null, - val minimum: Double? = null, - val maximum: Double? = null, + val minimum: SerializedBigDecimal? = null, + val maximum: SerializedBigDecimal? = null, ) \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt index 1c521e2ab3..645622ad8f 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt @@ -36,6 +36,7 @@ data class BalanceItem( val amount: BigDecimal, val pricePerShare: BigDecimal, val rawCurrencyId: String?, + val rawNetworkId: String, val validatorAddress: String?, val pendingActions: List, ) diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt index 3d3ae126da..01cc23bdc9 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalanceList.kt @@ -5,10 +5,13 @@ sealed class YieldBalanceList { data class Data( val balances: List, ) : YieldBalanceList() { - fun getBalance(rawCurrencyId: String?): YieldBalance { + fun getBalance(rawCurrencyId: String?, networkName: String): YieldBalance { return balances.firstOrNull { yield -> (yield as? YieldBalance.Data)?.balance?.items - ?.any { it.rawCurrencyId == rawCurrencyId } == true + ?.any { + rawCurrencyId == it.rawCurrencyId && + networkName.equals(it.rawNetworkId, ignoreCase = true) + } == true } ?: YieldBalance.Error } } diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt index 1caf4b8942..5a59ba211c 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/action/StakingActionCommonType.kt @@ -3,5 +3,6 @@ package com.tangem.domain.staking.model.stakekit.action enum class StakingActionCommonType { ENTER, EXIT, - PENDING, + PENDING_REWARDS, + PENDING_OTHER, } \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt index 2adfaed13b..605ed69fa5 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/transaction/ActionParams.kt @@ -12,6 +12,7 @@ data class ActionParams( val address: String, val validatorAddress: String, val token: Token, + val publicKey: String? = null, val passthrough: String? = null, val type: StakingActionType? = null, ) \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt index 381308b7a7..e5d79ff1b8 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/EstimateGasUseCase.kt @@ -6,6 +6,8 @@ import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId /** * Use case for staking gas estimation. @@ -15,9 +17,13 @@ class EstimateGasUseCase( private val stakingErrorResolver: StakingErrorResolver, ) { - suspend operator fun invoke(params: ActionParams): Either { + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): Either { return Either.catch { - stakingRepository.estimateGas(params) + stakingRepository.estimateGas(userWalletId, network, params) }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt new file mode 100644 index 0000000000..9e31da097f --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.repositories.StakingErrorResolver +import com.tangem.domain.staking.repositories.StakingRepository + +class GetConstructedStakingTransactionUseCase( + private val stakingRepository: StakingRepository, + private val stakingErrorResolver: StakingErrorResolver, +) { + + suspend operator fun invoke( + networkId: String, + fee: Fee, + transactionId: String, + ): Either> = Either.catch { + stakingRepository.constructTransaction(networkId, fee, transactionId) + }.mapLeft { + stakingErrorResolver.resolve(it) + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt index 02abd643a4..16ac583d13 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetStakingTransactionUseCase.kt @@ -6,6 +6,8 @@ import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.delay /** @@ -16,17 +18,18 @@ class GetStakingTransactionUseCase( private val stakingErrorResolver: StakingErrorResolver, ) { - suspend operator fun invoke(params: ActionParams): Either { + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): Either> { return Either.catch { - val createAction = stakingRepository.createAction(params) + val createAction = stakingRepository.createAction(userWalletId, network, params) // workaround, sometimes transaction is not created immediately after actions/enter delay(PATCH_TRANSACTION_REQUEST_DELAY) - val createdTransaction = createAction.transactions?.get(0) ?: error("No available transaction to patch") - val patchedTransaction = stakingRepository.constructTransaction(createdTransaction.id) - - patchedTransaction + createAction.transactions ?: error("No available transaction to patch") }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/IsApproveNeededUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/IsApproveNeededUseCase.kt new file mode 100644 index 0000000000..10bc37b3fa --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/IsApproveNeededUseCase.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.domain.staking.repositories.StakingErrorResolver +import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.model.CryptoCurrency + +class IsApproveNeededUseCase( + private val stakingRepository: StakingRepository, + private val stakingErrorResolver: StakingErrorResolver, +) { + operator fun invoke(cryptoCurrency: CryptoCurrency): Either { + return Either + .catch { stakingRepository.getStakingApproval(cryptoCurrency) } + .mapLeft { stakingErrorResolver.resolve(it) } + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt index a9ff51285e..5b6604423e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt @@ -1,7 +1,12 @@ package com.tangem.domain.staking.repositories +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.core.lce.LceFlow -import com.tangem.domain.staking.model.* +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList @@ -15,9 +20,10 @@ import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow +@Suppress("TooManyFunctions") interface StakingRepository { - fun isStakingSupported(currencyId: String): Boolean + fun isStakingSupported(integrationKey: String): Boolean suspend fun fetchEnabledYields(refresh: Boolean) @@ -61,11 +67,15 @@ interface StakingRepository { addresses: List, ): YieldBalanceList - suspend fun createAction(params: ActionParams): StakingAction + suspend fun createAction(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingAction - suspend fun estimateGas(params: ActionParams): StakingGasEstimate + suspend fun estimateGas(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingGasEstimate - suspend fun constructTransaction(transactionId: String): StakingTransaction + suspend fun constructTransaction( + networkId: String, + fee: Fee, + transactionId: String, + ): Pair suspend fun submitHash(transactionId: String, transactionHash: String) @@ -75,4 +85,7 @@ interface StakingRepository { /** Returns whether additional staking is possible if there is already active staking */ fun isStakeMoreAvailable(networkId: Network.ID): Boolean + + /** Returns staking approval */ + fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval } \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 989a7e9c5d..bbe01868f9 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -43,4 +43,7 @@ dependencies { /** Tests */ testImplementation(deps.test.junit) testImplementation(deps.test.coroutine) + testImplementation(deps.tangem.blockchain) { + exclude(module = "joda-time") + } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt index 8dee89d0e6..18f07bea3e 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesLceOperations.kt @@ -145,7 +145,10 @@ internal class CurrenciesStatusesLceOperations( currencies.map { currency -> val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance(currency.id.rawCurrencyId) + val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance( + rawCurrencyId = currency.id.rawCurrencyId, + networkName = currency.network.name, + ) createCurrencyStatus( currency = currency, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt index 34e8a290cb..e82bad56f2 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt @@ -261,7 +261,10 @@ internal class CurrenciesStatusesOperations( currencies.map { currency -> val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance(currency.id.rawCurrencyId) + val yieldBalance = (yieldBalances as? YieldBalanceList.Data)?.getBalance( + rawCurrencyId = currency.id.rawCurrencyId, + networkName = currency.network.name, + ) createCurrencyStatus( currency = currency, quote = quote, diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt index ad8a88a0fd..33c6654e89 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt @@ -1,12 +1,18 @@ package com.tangem.domain.tokens.repository +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionStatus +import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.core.lce.lceFlow -import com.tangem.domain.staking.model.* +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.UnsubmittedTransactionMetadata +import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus import com.tangem.domain.staking.model.stakekit.action.StakingActionType -import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.transaction.* import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency @@ -91,10 +97,10 @@ class MockStakingRepository : StakingRepository { ), tokens = listOf(), type = "auto", - rewardSchedule = "1", + rewardSchedule = Yield.Metadata.RewardSchedule.DAY, cooldownPeriod = Yield.Metadata.Period(days = 1), warmupPeriod = Yield.Metadata.Period(days = 1), - rewardClaiming = "1", + rewardClaiming = Yield.Metadata.RewardClaiming.AUTO, defaultValidator = null, minimumStake = null, supportsMultipleValidators = false, @@ -167,7 +173,11 @@ class MockStakingRepository : StakingRepository { balances = listOf(YieldBalance.Error), ) - override suspend fun createAction(params: ActionParams): StakingAction { + override suspend fun createAction( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingAction { return StakingAction( id = "quis", integrationId = "persequeris", @@ -182,7 +192,11 @@ class MockStakingRepository : StakingRepository { ) } - override suspend fun estimateGas(params: ActionParams): StakingGasEstimate { + override suspend fun estimateGas( + userWalletId: UserWalletId, + network: Network, + params: ActionParams, + ): StakingGasEstimate { return StakingGasEstimate( amount = BigDecimal(0.0001), token = Token( @@ -199,7 +213,11 @@ class MockStakingRepository : StakingRepository { ) } - override suspend fun constructTransaction(transactionId: String): StakingTransaction = StakingTransaction( + override suspend fun constructTransaction( + networkId: String, + fee: Fee, + transactionId: String, + ): Pair = StakingTransaction( id = "id", network = NetworkType.SOLANA, status = StakingTransactionStatus.SIGNED, @@ -214,6 +232,9 @@ class MockStakingRepository : StakingRepository { explorerUrl = null, ledgerHwAppId = null, isMessage = false, + ) to TransactionData.Compiled( + value = TransactionData.Compiled.Data.RawString(""), + status = TransactionStatus.Unconfirmed, ) override suspend fun submitHash(transactionId: String, transactionHash: String) { @@ -229,4 +250,6 @@ class MockStakingRepository : StakingRepository { } override fun isStakeMoreAvailable(networkId: Network.ID): Boolean = true + + override fun getStakingApproval(cryptoCurrency: CryptoCurrency): StakingApproval = StakingApproval.Empty } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt index 3fb7cbf0b5..6784c1726f 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt @@ -1,11 +1,16 @@ package com.tangem.domain.transaction -import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.CommonSigner +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionExtras import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionSendResult +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigDecimal import java.math.BigInteger interface TransactionRepository { @@ -20,7 +25,18 @@ interface TransactionRepository { network: Network, txExtras: TransactionExtras?, hash: String?, - ): TransactionData.Uncompiled? + ): TransactionData.Uncompiled + + @Suppress("LongParameterList") + suspend fun createApprovalTransaction( + amount: Amount, + fee: Fee, + contractAddress: String, + spenderAddress: String, + userWalletId: UserWalletId, + network: Network, + hash: String?, + ): TransactionData.Uncompiled @Suppress("LongParameterList") suspend fun validateTransaction( @@ -49,4 +65,10 @@ interface TransactionRepository { nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras + + suspend fun getAllowance( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency.Token, + spenderAddress: String, + ): BigDecimal } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt new file mode 100644 index 0000000000..01cf52d680 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.utils.convertToAmount +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigDecimal + +class CreateApprovalTransactionUseCase( + private val transactionRepository: TransactionRepository, +) { + + @Suppress("LongParameterList") + suspend operator fun invoke( + cryptoCurrency: CryptoCurrency.Token, + userWalletId: UserWalletId, + amount: BigDecimal, + fee: Fee, + contractAddress: String, + spenderAddress: String, + hash: String? = null, + ) = Either.catch { + transactionRepository.createApprovalTransaction( + amount = amount.convertToAmount(cryptoCurrency), + contractAddress = contractAddress, + spenderAddress = spenderAddress, + userWalletId = userWalletId, + network = cryptoCurrency.network, + fee = fee, + hash = hash, + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt index ab5172eaf8..87f10ea630 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt @@ -26,17 +26,15 @@ class CreateTransactionUseCase( txExtras: TransactionExtras? = null, hash: String? = null, ) = Either.catch { - requireNotNull( - transactionRepository.createTransaction( - amount = amount, - fee = fee, - memo = memo, - destination = destination, - userWalletId = userWalletId, - network = network, - txExtras = txExtras, - hash = hash, - ), - ) { "Failed to create transaction" } + transactionRepository.createTransaction( + amount = amount, + fee = fee, + memo = memo, + destination = destination, + userWalletId = userWalletId, + network = network, + txExtras = txExtras, + hash = hash, + ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt new file mode 100644 index 0000000000..316a1061ef --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetAllowanceUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.wallets.models.UserWalletId +import java.math.BigDecimal + +class GetAllowanceUseCase( + private val transactionRepository: TransactionRepository, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + spenderAddress: String, + ): Either { + return Either.catch { + transactionRepository.getAllowance( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency as CryptoCurrency.Token, + spenderAddress = spenderAddress, + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/api/build.gradle.kts b/features/manage-tokens/api/build.gradle.kts index 0cf94d3840..8bbbd4e02b 100644 --- a/features/manage-tokens/api/build.gradle.kts +++ b/features/manage-tokens/api/build.gradle.kts @@ -15,4 +15,7 @@ dependencies { /* Project - Core */ implementation(projects.core.ui) implementation(projects.core.decompose) + + /* Compose */ + implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/ManageTokensToggles.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/ManageTokensToggles.kt new file mode 100644 index 0000000000..09ad4a0dcd --- /dev/null +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/ManageTokensToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.managetokens + +interface ManageTokensToggles { + val isFeatureEnabled: Boolean +} \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt new file mode 100644 index 0000000000..761758d465 --- /dev/null +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/AddCustomTokenComponent.kt @@ -0,0 +1,18 @@ +package com.tangem.features.managetokens.component + +import androidx.compose.runtime.Composable +import com.tangem.domain.wallets.models.UserWalletId + +interface AddCustomTokenComponent { + + @Composable + fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory { + fun create(params: Params): AddCustomTokenComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt index 54cb358614..a38a96267c 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensComponent.kt @@ -2,13 +2,11 @@ package com.tangem.features.managetokens.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.wallets.models.UserWalletId interface ManageTokensComponent : ComposableContentComponent { - data class Params( - val userWalletId: UserWalletId, - ) + data class Params(val mode: Mode) + enum class Mode { READ_ONLY, MANAGE, } interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 8e3cc2bddb..1305e90261 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -19,6 +19,11 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.common.routing) + implementation(projects.core.featuretoggles) + + /* Project - Domain */ + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) /* AndroidX */ implementation(deps.androidx.activity.compose) @@ -28,6 +33,7 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.compose.foundation) + implementation(deps.compose.material) // For button colors implementation(deps.compose.material3) implementation(deps.compose.shimmer) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/DefaultManageTokensToggles.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/DefaultManageTokensToggles.kt new file mode 100644 index 0000000000..ea923c7772 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/DefaultManageTokensToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.managetokens + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager + +internal class DefaultManageTokensToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : ManageTokensToggles { + + override val isFeatureEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("NEW_MANAGE_TOKENS") +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt new file mode 100644 index 0000000000..acb8dc42a4 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenFormComponent.kt @@ -0,0 +1,19 @@ +package com.tangem.features.managetokens.component + +import androidx.compose.foundation.lazy.LazyListScope +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +internal interface CustomTokenFormComponent { + + fun content(scope: LazyListScope) + + data class Params( + val userWalletId: UserWalletId, + val networkId: Network.ID, + ) + + interface Factory { + fun create(params: Params): CustomTokenFormComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt new file mode 100644 index 0000000000..a2551d430c --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/CustomTokenNetworkSelectorComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.managetokens.component + +import androidx.compose.foundation.lazy.LazyListScope +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.entity.SelectedNetworkUM + +internal interface CustomTokenNetworkSelectorComponent { + + fun content(scope: LazyListScope) + + data class Params( + val userWalletId: UserWalletId, + val selectedNetwork: SelectedNetworkUM?, + val onNetworkSelected: (SelectedNetworkUM) -> Unit, + ) + + interface Factory { + fun create(params: Params): CustomTokenNetworkSelectorComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt new file mode 100644 index 0000000000..e89139cf5f --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultManageTokensComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.managetokens.component.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.model.ManageTokensModel +import com.tangem.features.managetokens.ui.ManageTokensScreen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultManageTokensComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: ManageTokensComponent.Params, +) : ManageTokensComponent, AppComponentContext by context { + + private val model: ManageTokensModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + ManageTokensScreen( + modifier = modifier, + state = state, + ) + } + + @AssistedFactory + interface Factory : ManageTokensComponent.Factory { + override fun create( + context: AppComponentContext, + params: ManageTokensComponent.Params, + ): DefaultManageTokensComponent + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt new file mode 100644 index 0000000000..b428c0ed2d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewAddCustomTokenComponent.kt @@ -0,0 +1,84 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM +import com.tangem.features.managetokens.entity.AddCustomTokenUM +import com.tangem.features.managetokens.entity.ClickableFieldUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.AddCustomTokenBottomSheet +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +internal class PreviewAddCustomTokenComponent( + initialState: AddCustomTokenUM = AddCustomTokenUM.NetworkSelector(popBack = {}), +) : AddCustomTokenComponent { + + private val userWalletId = UserWalletId(stringValue = "321") + + private val previewState: MutableStateFlow = MutableStateFlow(initialState) + + @Composable + override fun BottomSheet(isVisible: Boolean, onDismiss: () -> Unit) { + val state by previewState.collectAsStateWithLifecycle() + val config = TangemBottomSheetConfig( + isShow = isVisible, + onDismissRequest = onDismiss, + content = state, + ) + + AddCustomTokenBottomSheet( + config = config, + content = { + when (val s = state) { + is AddCustomTokenUM.Form -> { + PreviewCustomTokenFormComponent( + networkName = ClickableFieldUM( + label = resourceReference(R.string.custom_token_network_input_title), + value = stringReference(s.selectedNetwork.name), + onClick = { showNetworkSelector(s.selectedNetwork) }, + ), + ).content(this) + } + is AddCustomTokenUM.NetworkSelector -> { + PreviewCustomTokenNetworkSelectorComponent( + params = CustomTokenNetworkSelectorComponent.Params( + userWalletId = userWalletId, + selectedNetwork = s.selectedNetwork, + onNetworkSelected = ::showForm, + ), + networksSize = 20, + ).content(this) + } + } + }, + ) + } + + private fun showNetworkSelector(selectedNetwork: SelectedNetworkUM) { + previewState.update { + AddCustomTokenUM.NetworkSelector(selectedNetwork, popBack = { showForm(selectedNetwork) }) + } + } + + private fun showForm(network: SelectedNetworkUM) { + previewState.update { + AddCustomTokenUM.Form( + popBack = {}, + selectedNetwork = network, + addTokenButton = AddCustomTokenButtonUM.Visible( + isEnabled = false, + onClick = {}, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt new file mode 100644 index 0000000000..625090f28d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenFormComponent.kt @@ -0,0 +1,81 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.foundation.lazy.LazyListScope +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.managetokens.component.CustomTokenFormComponent +import com.tangem.features.managetokens.entity.ClickableFieldUM +import com.tangem.features.managetokens.entity.CustomTokenFormUM +import com.tangem.features.managetokens.entity.TextInputFieldUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.customTokenFormContent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class PreviewCustomTokenFormComponent( + networkName: ClickableFieldUM = ClickableFieldUM( + label = resourceReference(R.string.custom_token_network_input_title), + value = stringReference(value = "Ethereum"), + onClick = {}, + ), + derivationPath: ClickableFieldUM = ClickableFieldUM( + label = resourceReference(R.string.custom_token_derivation_path), + value = stringReference(value = "Default"), + onClick = {}, + ), + canAddToken: Boolean = false, + contractAddress: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_contract_address_input_title), + placeholder = stringReference(value = "0x000000000000000000000000000"), + value = "", + onValueChange = {}, + ), + tokenName: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_name_input_title), + placeholder = stringReference(value = "E.g. USD Coin"), + value = "", + onValueChange = {}, + ), + tokenSymbol: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_token_symbol_input_title), + placeholder = stringReference(value = "E.g. USDC"), + value = "", + onValueChange = {}, + ), + tokenDecimals: TextInputFieldUM = TextInputFieldUM( + label = resourceReference(R.string.custom_token_decimals_input_title), + placeholder = stringReference(value = "8"), + value = "", + onValueChange = {}, + ), + notifications: ImmutableList = persistentListOf( + CustomTokenFormUM.NotificationUM( + id = "1", + config = NotificationConfig( + title = stringReference(value = "Note that tokens can be created by anyone"), + subtitle = stringReference(value = "Be aware of adding scam tokens, they can cost nothing"), + iconResId = R.drawable.img_attention_20, + ), + ), + ), +) : CustomTokenFormComponent { + + private val previewState = CustomTokenFormUM( + networkName = networkName, + contractAddress = contractAddress, + tokenName = tokenName, + tokenSymbol = tokenSymbol, + tokenDecimals = tokenDecimals, + derivationPath = derivationPath, + notifications = notifications, + canAddToken = canAddToken, + onDerivationPathClick = {}, + onNetworkClick = {}, + onAddClick = {}, + ) + + override fun content(scope: LazyListScope) { + scope.customTokenFormContent(model = previewState) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt new file mode 100644 index 0000000000..620dbe1222 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenNetworkSelectorComponent.kt @@ -0,0 +1,50 @@ +package com.tangem.features.managetokens.component.preview + +import androidx.compose.foundation.lazy.LazyListScope +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.entity.CurrencyNetworkUM +import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R +import com.tangem.features.managetokens.ui.customTokenNetworkSelectorContent +import kotlinx.collections.immutable.toImmutableList + +internal class PreviewCustomTokenNetworkSelectorComponent( + private val params: CustomTokenNetworkSelectorComponent.Params = CustomTokenNetworkSelectorComponent.Params( + userWalletId = UserWalletId(stringValue = "321"), + selectedNetwork = null, + onNetworkSelected = {}, + ), + networksSize: Int = 5, +) : CustomTokenNetworkSelectorComponent { + + private val previewNetworks = List(size = networksSize) { networkIndex -> + val n = SelectedNetworkUM( + id = Network.ID(networkIndex.toString()), + name = "Network $networkIndex", + ) + + CurrencyNetworkUM( + id = n.id, + name = n.name, + type = "N$networkIndex", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = false, + isSelected = n.id == params.selectedNetwork?.id, + onSelectedStateChange = { params.onNetworkSelected(n) }, + ) + }.toImmutableList() + + private val previewState = CustomTokenNetworkSelectorUM( + showTitle = params.selectedNetwork == null, + networks = previewNetworks, + ) + + override fun content(scope: LazyListScope) { + scope.customTokenNetworkSelectorContent( + model = previewState, + ) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index 8fabf7cfd4..980afcb895 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -6,15 +6,14 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.core.ui.components.rows.model.ChainRowUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.tokens.model.Network import com.tangem.features.managetokens.component.ManageTokensComponent -import com.tangem.features.managetokens.entity.CurrencyItemUM -import com.tangem.features.managetokens.entity.CurrencyNetworkUM -import com.tangem.features.managetokens.entity.ManageTokensUM +import com.tangem.features.managetokens.entity.* import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.ui.ManageTokensScreen import kotlinx.collections.immutable.mutate @@ -28,11 +27,18 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { private val changedItemsIds: MutableSet = mutableSetOf() private var items = initItems() - private val previewState = MutableStateFlow( - value = ManageTokensUM( + value = ManageTokensUM.ManageContent( popBack = {}, items = items, + topBar = ManageTokensTopBarUM.ManageContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = {}, + endButton = TopAppBarButtonUM( + iconRes = R.drawable.ic_plus_24, + onIconClicked = {}, + ), + ), search = SearchBarUM( placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), query = "", @@ -41,8 +47,8 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { onActiveChange = ::toggleSearchBar, ), hasChanges = false, + isLoading = false, onSaveClick = {}, - onAddCustomToken = {}, ), ) @@ -129,14 +135,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex -> CurrencyNetworkUM( - id = networkIndex.toString(), - model = BlockchainRowUM( - name = "NETWORK$networkIndex", - type = "N$networkIndex", - iconResId = R.drawable.ic_eth_16, - isMainNetwork = networkIndex == 0, - isSelected = false, - ), + id = Network.ID(networkIndex.toString()), + name = "NETWORK$networkIndex", + type = "N$networkIndex", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = networkIndex == 0, isSelected = false, onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) }, ) @@ -171,14 +174,11 @@ internal class PreviewManageTokensComponent : ManageTokensComponent { it.fastForEachIndexed { index, network -> if (index == networkIndex) { it[index] = network.copy( - model = network.model.copy( - iconResId = if (isSelected) { - R.drawable.img_eth_22 - } else { - R.drawable.ic_eth_16 - }, - isSelected = isSelected, - ), + iconResId = if (isSelected) { + R.drawable.img_eth_22 + } else { + R.drawable.ic_eth_16 + }, isSelected = isSelected, ) } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt new file mode 100644 index 0000000000..f84b3c4c8e --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.managetokens.di + +import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.component.impl.DefaultManageTokensComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindManageTokensComponentFactory(factory: DefaultManageTokensComponent.Factory): ManageTokensComponent.Factory +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/FeatureModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/FeatureModule.kt new file mode 100644 index 0000000000..9c181b867e --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/FeatureModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.managetokens.di + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager +import com.tangem.features.managetokens.DefaultManageTokensToggles +import com.tangem.features.managetokens.ManageTokensToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object FeatureModule { + + @Provides + @Singleton + fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): ManageTokensToggles = + DefaultManageTokensToggles(featureTogglesManager = featureTogglesManager) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt new file mode 100644 index 0000000000..572c2624cb --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.managetokens.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.managetokens.model.ManageTokensModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(ManageTokensModel::class) + fun provideManageTokensModel(model: ManageTokensModel): Model +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt new file mode 100644 index 0000000000..a34939d61f --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/AddCustomTokenUM.kt @@ -0,0 +1,54 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.domain.tokens.model.Network + +@Immutable +internal sealed class AddCustomTokenUM : TangemBottomSheetConfigContent { + + abstract val selectedNetwork: SelectedNetworkUM? + abstract val addTokenButton: AddCustomTokenButtonUM + + abstract val popBack: () -> Unit + + data class NetworkSelector( + override val selectedNetwork: SelectedNetworkUM? = null, + override val popBack: () -> Unit, + ) : AddCustomTokenUM() { + + override val addTokenButton: AddCustomTokenButtonUM = AddCustomTokenButtonUM.Hidden + } + + data class Form( + override val selectedNetwork: SelectedNetworkUM, + override val addTokenButton: AddCustomTokenButtonUM.Visible, + override val popBack: () -> Unit, + ) : AddCustomTokenUM() +} + +@Immutable +internal data class SelectedNetworkUM( + val id: Network.ID, + val name: String, +) + +@Immutable +internal sealed class AddCustomTokenButtonUM { + + open val onClick: () -> Unit = {} + + open val isEnabled: Boolean = false + + val isVisible: Boolean + get() = this is Visible + + data object Hidden : AddCustomTokenButtonUM() { + override val onClick: () -> Unit = {} + } + + data class Visible( + override val isEnabled: Boolean, + override val onClick: () -> Unit, + ) : AddCustomTokenButtonUM() +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt index 8ed8dc5f9d..477585db3f 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CurrencyNetworkUM.kt @@ -1,12 +1,15 @@ package com.tangem.features.managetokens.entity import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.domain.tokens.model.Network @Immutable internal data class CurrencyNetworkUM( - val id: String, - val model: BlockchainRowUM, + val id: Network.ID, + val name: String, + val type: String, + val iconResId: Int, + val isMainNetwork: Boolean, val isSelected: Boolean, val onSelectedStateChange: (Boolean) -> Unit, ) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt new file mode 100644 index 0000000000..ea9bfbceb0 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenFormUM.kt @@ -0,0 +1,44 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class CustomTokenFormUM( + val networkName: ClickableFieldUM, + val contractAddress: TextInputFieldUM, + val tokenName: TextInputFieldUM, + val tokenSymbol: TextInputFieldUM, + val tokenDecimals: TextInputFieldUM, + val derivationPath: ClickableFieldUM, + val notifications: ImmutableList, + val canAddToken: Boolean, + val onNetworkClick: () -> Unit, + val onDerivationPathClick: () -> Unit, + val onAddClick: () -> Unit, +) { + + @Immutable + data class NotificationUM( + val id: String, + val config: NotificationConfig, + ) +} + +@Immutable +internal data class TextInputFieldUM( + val label: TextReference, + val placeholder: TextReference, + val value: String, + val onValueChange: (String) -> Unit, + val error: TextReference? = null, +) + +@Immutable +internal data class ClickableFieldUM( + val label: TextReference, + val value: TextReference, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt new file mode 100644 index 0000000000..1435d25f7d --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/CustomTokenNetworkSelectorUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class CustomTokenNetworkSelectorUM( + val showTitle: Boolean, + val networks: ImmutableList, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt new file mode 100644 index 0000000000..b2f08a0ef0 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensTopBarUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.managetokens.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed class ManageTokensTopBarUM { + + abstract val title: TextReference + abstract val onBackButtonClick: () -> Unit + + data class ReadContent( + override val title: TextReference, + override val onBackButtonClick: () -> Unit, + ) : ManageTokensTopBarUM() + + data class ManageContent( + override val title: TextReference, + override val onBackButtonClick: () -> Unit, + val endButton: TopAppBarButtonUM, + ) : ManageTokensTopBarUM() +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt index c8678bdd38..d53e7e5e4d 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/entity/ManageTokensUM.kt @@ -5,11 +5,40 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import kotlinx.collections.immutable.ImmutableList @Immutable -internal data class ManageTokensUM( - val popBack: () -> Unit, - val items: ImmutableList, - val search: SearchBarUM, - val hasChanges: Boolean, - val onAddCustomToken: () -> Unit, - val onSaveClick: () -> Unit, -) \ No newline at end of file +internal sealed class ManageTokensUM { + + abstract val popBack: () -> Unit + abstract val isLoading: Boolean + abstract val items: ImmutableList + abstract val topBar: ManageTokensTopBarUM + abstract val search: SearchBarUM + + data class ReadContent( + override val popBack: () -> Unit, + override val isLoading: Boolean, + override val items: ImmutableList, + override val topBar: ManageTokensTopBarUM, + override val search: SearchBarUM, + ) : ManageTokensUM() + + data class ManageContent( + override val popBack: () -> Unit, + override val isLoading: Boolean, + override val items: ImmutableList, + override val topBar: ManageTokensTopBarUM, + override val search: SearchBarUM, + val onSaveClick: () -> Unit, + val hasChanges: Boolean, + ) : ManageTokensUM() + + fun copySealed( + search: SearchBarUM = this.search, + items: ImmutableList = this.items, + hasChanges: Boolean = this is ManageContent && this.hasChanges, + ): ManageTokensUM { + return when (this) { + is ManageContent -> copy(search = search, items = items, hasChanges = hasChanges) + is ReadContent -> copy(search = search, items = items) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt new file mode 100644 index 0000000000..6141538abd --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -0,0 +1,243 @@ +package com.tangem.features.managetokens.model + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.util.fastForEachIndexed +import com.tangem.core.decompose.di.ComponentScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.component.ManageTokensComponent +import com.tangem.features.managetokens.entity.* +import com.tangem.features.managetokens.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ComponentScoped +internal class ManageTokensModel @Inject constructor( + paramsContainer: ParamsContainer, + private val router: Router, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params: ManageTokensComponent.Params = paramsContainer.require() + private val changedItemsIds: MutableSet = mutableSetOf() + private var items = initItems() + + val state: MutableStateFlow = MutableStateFlow(value = getInitialState(mode = params.mode)) + + private fun getInitialState(mode: ManageTokensComponent.Mode): ManageTokensUM { + return when (mode) { + ManageTokensComponent.Mode.READ_ONLY -> createReadContentModel() + ManageTokensComponent.Mode.MANAGE -> createManageContentModel() + } + } + + private fun createReadContentModel(): ManageTokensUM.ReadContent { + return ManageTokensUM.ReadContent( + popBack = router::pop, + isLoading = false, + items = initItems(), + topBar = ManageTokensTopBarUM.ReadContent( + title = resourceReference(R.string.common_search_tokens), + onBackButtonClick = router::pop, + ), + search = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = "", + onQueryChange = ::searchCurrencies, + isActive = false, + onActiveChange = ::toggleSearchBar, + ), + ) + } + + private fun createManageContentModel(): ManageTokensUM.ManageContent { + return ManageTokensUM.ManageContent( + popBack = router::pop, + isLoading = false, + items = initItems(), + topBar = ManageTokensTopBarUM.ManageContent( + title = resourceReference(id = R.string.main_manage_tokens), + onBackButtonClick = router::pop, + endButton = TopAppBarButtonUM( + iconRes = R.drawable.ic_plus_24, + onIconClicked = ::onAddCustomToken, + ), + ), + search = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = "", + onQueryChange = ::searchCurrencies, + isActive = false, + onActiveChange = ::toggleSearchBar, + ), + onSaveClick = ::onSaveClick, + hasChanges = false, + ) + } + + private fun onAddCustomToken() { + // TODO: [REDACTED_JIRA] + } + + private fun onSaveClick() { + // TODO: [REDACTED_JIRA] + } + + @Suppress("UnusedPrivateMember") + private fun searchCurrencies(query: String) { + // TODO: [REDACTED_JIRA] + val newItems = if (query.isBlank()) { + initItems() + } else { + state.value.items.filter { currency -> + currency.model.name.contains(query, ignoreCase = true) + }.toPersistentList() + } + state.update { state -> + state.copySealed(search = state.search.copy(query = query), items = newItems) + } + } + + private fun toggleSearchBar(isActive: Boolean) { + state.update { state -> + state.copySealed( + search = state.search.copy(isActive = isActive), + ) + } + } + + private fun initItems() = List(size = 30) { index -> + if (index < 2) { + getCustomItem(index) + } else { + getBasicItem(index) + } + }.toPersistentList() + + private fun getCustomItem(index: Int) = CurrencyItemUM.Custom( + id = index.toString(), + model = ChainRowUM( + name = "Custom token $index", + type = "CT$index", + icon = CurrencyIconState.CustomTokenIcon( + tint = Color.White, + background = Color.Black, + topBadgeIconResId = R.drawable.img_eth_22, + isGrayscale = false, + showCustomBadge = true, + ), + showCustom = true, + ), + onRemoveClick = {}, + ) + + private fun getBasicItem(index: Int) = CurrencyItemUM.Basic( + id = index.toString(), + model = ChainRowUM( + name = "Currency $index", + type = "C$index", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_btc_22, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ), + networks = if (index == 2) { + CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index)) + } else { + CurrencyItemUM.Basic.NetworksUM.Collapsed + }, + onExpandClick = { toggleCurrency(index) }, + ) + + private fun getCurrencyNetworks(currencyIndex: Int) = List(size = 3) { networkIndex -> + CurrencyNetworkUM( + id = Network.ID(networkIndex.toString()), + name = "NETWORK$networkIndex", + type = "N$networkIndex", + iconResId = R.drawable.ic_eth_16, + isMainNetwork = networkIndex == 0, + isSelected = false, + onSelectedStateChange = { toggleNetwork(currencyIndex, networkIndex, isSelected = it) }, + ) + }.toImmutableList() + + private fun toggleCurrency(index: Int) { + val updatedItem = when (val item = items[index]) { + is CurrencyItemUM.Basic -> item.copy( + networks = if (item.networks is CurrencyItemUM.Basic.NetworksUM.Collapsed) { + CurrencyItemUM.Basic.NetworksUM.Expanded(getCurrencyNetworks(index)) + } else { + CurrencyItemUM.Basic.NetworksUM.Collapsed + }, + ) + is CurrencyItemUM.Custom -> return + } + + state.update { state -> + items = items.mutate { + it[index] = updatedItem + } + state.copySealed(items = items) + } + } + + private fun toggleNetwork(currencyIndex: Int, networkIndex: Int, isSelected: Boolean) { + val updatedItem = when (val item = items[currencyIndex]) { + is CurrencyItemUM.Basic -> { + val updatedNetworks = (item.networks as? CurrencyItemUM.Basic.NetworksUM.Expanded) + ?.copy( + networks = item.networks.networks.toPersistentList().mutate { + it.fastForEachIndexed { index, network -> + if (index == networkIndex) { + it[index] = network.copy( + iconResId = if (isSelected) { + R.drawable.img_eth_22 + } else { + R.drawable.ic_eth_16 + }, + isSelected = isSelected, + ) + } + } + }, + ) + ?: return + + item.copy(networks = updatedNetworks) + } + is CurrencyItemUM.Custom -> return + } + + val id = "${currencyIndex}_$networkIndex" + if (changedItemsIds.contains(id)) { + changedItemsIds.remove(id) + } else { + changedItemsIds.add(id) + } + + state.update { state -> + items = items.mutate { + it[currencyIndex] = updatedItem + } + state.copySealed( + items = items, + hasChanges = changedItemsIds.isNotEmpty(), + ) + } + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt new file mode 100644 index 0000000000..a002b54b4c --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt @@ -0,0 +1,188 @@ +package com.tangem.features.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.isOpened +import com.tangem.core.ui.components.keyboardAsState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.tokens.model.Network +import com.tangem.features.managetokens.component.AddCustomTokenComponent +import com.tangem.features.managetokens.component.preview.PreviewAddCustomTokenComponent +import com.tangem.features.managetokens.entity.AddCustomTokenButtonUM +import com.tangem.features.managetokens.entity.AddCustomTokenUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig, content: LazyListScope.() -> Unit) { + TangemBottomSheet( + config = config, + title = { model -> + Title(model) + }, + containerColor = TangemTheme.colors.background.secondary, + content = { model -> + Content( + model = model, + content = content, + ) + }, + ) +} + +@Composable +private fun Title(model: AddCustomTokenUM, modifier: Modifier = Modifier) { + val showTokenNetworkTitle = model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null + + if (showTokenNetworkTitle) { + TangemTopAppBar( + modifier = modifier, + title = resourceReference(R.string.custom_token_network_selector_title), + titleAlignment = Alignment.CenterHorizontally, + startButton = TopAppBarButtonUM.Back(model.popBack), + height = TangemTopAppBarHeight.BOTTOM_SHEET, + ) + } else { + TangemBottomSheetTitle( + modifier = modifier, + title = resourceReference(R.string.add_custom_token_title), + ) + } +} + +@Composable +private fun Content(model: AddCustomTokenUM, content: LazyListScope.() -> Unit, modifier: Modifier = Modifier) { + val density = LocalDensity.current + val keyboardState by keyboardAsState() + + var fabHeight by remember { mutableStateOf(0.dp) } + + Scaffold( + modifier = modifier.imePadding(), + containerColor = TangemTheme.colors.background.secondary, + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + AnimatedVisibility( + modifier = Modifier.onSizeChanged { + fabHeight = with(density) { it.height.toDp() } + }, + visible = model.addTokenButton.isVisible && !keyboardState.isOpened, + enter = fadeIn(), + exit = fadeOut(), + label = "Add button visibility", + ) { + PrimaryButton( + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + text = stringResource(id = R.string.custom_token_add_token), + enabled = model.addTokenButton.isEnabled, + onClick = model.addTokenButton.onClick, + ) + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier.padding(paddingValues), + contentPadding = PaddingValues( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32 + fabHeight, + ), + ) { + item { + if (model is AddCustomTokenUM.NetworkSelector && model.selectedNetwork != null) { + Spacer(modifier = Modifier.size(TangemTheme.dimens.spacing12)) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = TangemTheme.dimens.spacing16), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + text = stringResource(id = R.string.custom_token_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + } + } + } + + content() + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_AddCustomTokenBottomSheet( + @PreviewParameter(AddCustomTokenComponentPreviewProvider::class) component: AddCustomTokenComponent, +) { + TangemThemePreview { + component.BottomSheet(isVisible = true, onDismiss = {}) + } +} + +private class AddCustomTokenComponentPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewAddCustomTokenComponent(), + PreviewAddCustomTokenComponent( + initialState = AddCustomTokenUM.NetworkSelector( + popBack = {}, + selectedNetwork = SelectedNetworkUM( + id = Network.ID(value = "0"), + name = "Ethereum", + ), + ), + ), + PreviewAddCustomTokenComponent( + initialState = AddCustomTokenUM.Form( + popBack = {}, + selectedNetwork = SelectedNetworkUM( + id = Network.ID(value = "1"), + name = "Ethereum", + ), + addTokenButton = AddCustomTokenButtonUM.Visible( + isEnabled = false, + onClick = {}, + ), + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt new file mode 100644 index 0000000000..e898e44150 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenFormContent.kt @@ -0,0 +1,196 @@ +package com.tangem.features.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.fields.SimpleTextField +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.managetokens.component.preview.PreviewCustomTokenFormComponent +import com.tangem.features.managetokens.entity.ClickableFieldUM +import com.tangem.features.managetokens.entity.CustomTokenFormUM +import com.tangem.features.managetokens.entity.TextInputFieldUM + +internal fun LazyListScope.customTokenFormContent(model: CustomTokenFormUM) { + item { + ClickableField( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + model = model.networkName, + ) + } + + item { + Column( + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing12) + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) { + TextField( + model = model.contractAddress, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = model.tokenName, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = model.tokenSymbol, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + ), + ) + TextField( + model = model.tokenDecimals, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + ) + } + } + + item { + ClickableField( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + model = model.derivationPath, + ) + } + + items( + items = model.notifications, + key = { it.id }, + ) { notification -> + Notification( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + config = notification.config, + containerColor = TangemTheme.colors.button.disabled, + ) + } +} + +@Composable +private fun TextField( + model: TextInputFieldUM, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + InformationBlock( + modifier = modifier, + title = { + val color by animateColorAsState( + targetValue = if (model.error != null) { + TangemTheme.colors.text.warning + } else { + TangemTheme.colors.text.tertiary + }, + label = "Field label color", + ) + + Text( + text = (model.error ?: model.label).resolveReference(), + style = TangemTheme.typography.subtitle2, + color = color, + ) + }, + content = { + SimpleTextField( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + value = model.value, + onValueChange = model.onValueChange, + readOnly = false, + placeholder = model.placeholder, + singleLine = true, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + }, + ) +} + +@Composable +private fun ClickableField(model: ClickableFieldUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .clickable(onClick = model.onClick), + title = { + Text( + text = model.label.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + }, + content = { + Text( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), + text = model.value.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + }, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_CustomTokenFormContent( + @PreviewParameter(PreviewCustomTokenFormComponentProvider::class) + component: PreviewCustomTokenFormComponent, +) { + TangemThemePreview { + LazyColumn( + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + ) { component.content(scope = this) } + } +} + +private class PreviewCustomTokenFormComponentProvider : + PreviewParameterProvider { + + override val values: Sequence + get() = sequenceOf( + PreviewCustomTokenFormComponent(), + PreviewCustomTokenFormComponent( + contractAddress = TextInputFieldUM( + label = stringReference("Contract address"), + value = "0x1234567890", + error = stringReference("Contract address is invalid"), + placeholder = stringReference("0x1234567890"), + onValueChange = {}, + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt new file mode 100644 index 0000000000..57abdb15b5 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/CustomTokenNetworkSelectorContent.kt @@ -0,0 +1,158 @@ +package com.tangem.features.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.rows.ChainRow +import com.tangem.core.ui.components.rows.model.ChainRowUM +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.managetokens.component.CustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.component.preview.PreviewCustomTokenNetworkSelectorComponent +import com.tangem.features.managetokens.entity.CurrencyNetworkUM +import com.tangem.features.managetokens.entity.CustomTokenNetworkSelectorUM +import com.tangem.features.managetokens.entity.SelectedNetworkUM +import com.tangem.features.managetokens.impl.R + +internal fun LazyListScope.customTokenNetworkSelectorContent(model: CustomTokenNetworkSelectorUM) { + val lastIndex = model.networks.lastIndex + + if (model.showTitle) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size36) + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.bottomSheet, + ), + ) { + Text( + modifier = Modifier + .padding( + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing6, + ) + .padding(horizontal = TangemTheme.dimens.spacing12), + text = stringResource(R.string.add_custom_token_choose_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + + itemsIndexed( + items = model.networks, + key = { _, item -> item.id.value }, + ) { index, item -> + NetworkItem( + modifier = Modifier + .fillMaxWidth() + .clip( + shape = when { + !model.showTitle && index == 0 -> RoundedCornerShape( + topStart = TangemTheme.dimens.radius16, + topEnd = TangemTheme.dimens.radius16, + ) + index == lastIndex -> RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ) + else -> RectangleShape + }, + ) + .background(color = TangemTheme.colors.background.primary) + .clickable(onClick = { item.onSelectedStateChange(true) }) + .padding(horizontal = TangemTheme.dimens.spacing4), + model = item, + ) + } +} + +@Composable +private fun NetworkItem(model: CurrencyNetworkUM, modifier: Modifier = Modifier) { + ChainRow( + modifier = modifier, + model = with(model) { + ChainRowUM( + name = name, + type = type, + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = model.iconResId, + isGrayscale = false, + showCustomBadge = false, + ), + showCustom = false, + ) + }, + action = { + AnimatedVisibility( + modifier = Modifier.size(TangemTheme.dimens.size24), + visible = model.isSelected, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + }, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_CustomTokenNetworkSelectorContent( + @PreviewParameter(CustomTokenNetworkSelectorComponentPreviewProvider::class) + component: CustomTokenNetworkSelectorComponent, +) { + TangemThemePreview { + LazyColumn { + component.content(this) + } + } +} + +private class CustomTokenNetworkSelectorComponentPreviewProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + PreviewCustomTokenNetworkSelectorComponent(), + PreviewCustomTokenNetworkSelectorComponent( + params = CustomTokenNetworkSelectorComponent.Params( + userWalletId = UserWalletId(stringValue = "321"), + selectedNetwork = SelectedNetworkUM( + id = Network.ID(value = "0"), + name = "", + ), + onNetworkSelected = {}, + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt index 63a14af8d4..9f9584fac9 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/ManageTokensScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold @@ -35,12 +36,15 @@ import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.rows.ArrowRow import com.tangem.core.ui.components.rows.BlockchainRow import com.tangem.core.ui.components.rows.ChainRow +import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.managetokens.component.preview.PreviewManageTokensComponent import com.tangem.features.managetokens.entity.CurrencyItemUM import com.tangem.features.managetokens.entity.CurrencyItemUM.Basic.NetworksUM +import com.tangem.features.managetokens.entity.ManageTokensTopBarUM import com.tangem.features.managetokens.entity.ManageTokensUM import com.tangem.features.managetokens.impl.R import kotlinx.collections.immutable.ImmutableList @@ -56,14 +60,9 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi modifier = modifier, containerColor = TangemTheme.colors.background.primary, topBar = { - TangemTopAppBar( + ManageTokensTopBar( modifier = Modifier.statusBarsPadding(), - title = stringResource(id = R.string.main_manage_tokens), - startButton = TopAppBarButtonUM.Back(state.popBack), - endButton = TopAppBarButtonUM( - iconRes = R.drawable.ic_plus_24, - onIconClicked = state.onAddCustomToken, - ), + topBar = state.topBar, ) }, content = { innerPadding -> @@ -71,18 +70,36 @@ internal fun ManageTokensScreen(state: ManageTokensUM, modifier: Modifier = Modi modifier = Modifier .padding(innerPadding) .fillMaxSize(), - state = state, + search = state.search, + items = state.items, + isLoading = state.isLoading, + hasChanges = state is ManageTokensUM.ManageContent && state.hasChanges, ) }, floatingActionButtonPosition = FabPosition.Center, floatingActionButton = { - SaveChangesButton( - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), - isVisible = state.hasChanges, - onClick = state.onSaveClick, - ) + if (state is ManageTokensUM.ManageContent) { + SaveChangesButton( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + isVisible = state.hasChanges, + onClick = state.onSaveClick, + ) + } + }, + ) +} + +@Composable +private fun ManageTokensTopBar(topBar: ManageTokensTopBarUM, modifier: Modifier = Modifier) { + TangemTopAppBar( + modifier = modifier, + title = topBar.title.resolveReference(), + startButton = TopAppBarButtonUM.Back(topBar.onBackButtonClick), + endButton = when (topBar) { + is ManageTokensTopBarUM.ManageContent -> topBar.endButton + is ManageTokensTopBarUM.ReadContent -> null }, ) } @@ -105,24 +122,48 @@ private fun SaveChangesButton(isVisible: Boolean, onClick: () -> Unit, modifier: } @Composable -private fun Content(state: ManageTokensUM, modifier: Modifier = Modifier) { +private fun LoadingContent() { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = TangemTheme.colors.background.primary), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = TangemTheme.colors.icon.accent) + } +} + +@Composable +private fun Content( + search: SearchBarUM, + items: ImmutableList, + isLoading: Boolean, + hasChanges: Boolean, + modifier: Modifier = Modifier, +) { Box(modifier = modifier) { Currencies( modifier = Modifier.fillMaxSize(), - items = state.items, - search = state.search, + items = items, + search = search, ) AnimatedVisibility( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth(), - visible = state.hasChanges, + visible = hasChanges, label = "bottom_fade_visibility", ) { BottomFade() } } + + Crossfade(targetState = isLoading, label = "ManageTokensLoadingContent") { + if (it) { + LoadingContent() + } + } } @OptIn(ExperimentalFoundationApi::class) @@ -245,7 +286,15 @@ private fun NetworksList(networks: NetworksUM, currencyId: String, modifier: Mod isLastItem = index == currentItems.lastIndex, content = { BlockchainRow( - model = network.model, + model = with(network) { + BlockchainRowUM( + name = name, + type = type, + iconResId = iconResId, + isMainNetwork = isMainNetwork, + isSelected = isSelected, + ) + }, action = { TangemSwitch( checked = network.isSelected, diff --git a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsListComponent.kt b/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt similarity index 82% rename from features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsListComponent.kt rename to features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt index c0d14dbea5..21ec5c5772 100644 --- a/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsListComponent.kt +++ b/features/markets/api/src/main/kotlin/com/tangem/features/markets/component/MarketsEntryComponent.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.unit.Dp import com.tangem.core.decompose.context.AppComponentContext @Stable -interface MarketsListComponent { +interface MarketsEntryComponent { @Composable fun BottomSheetContent( @@ -18,6 +18,6 @@ interface MarketsListComponent { ) interface Factory { - fun create(context: AppComponentContext): MarketsListComponent + fun create(context: AppComponentContext): MarketsEntryComponent } } \ No newline at end of file diff --git a/features/markets/impl/build.gradle.kts b/features/markets/impl/build.gradle.kts index 46bc8c674a..7d8b7d0667 100644 --- a/features/markets/impl/build.gradle.kts +++ b/features/markets/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { /* Project - API */ api(projects.features.markets.api) + implementation(projects.core.navigation) /* Domain */ implementation(projects.domain.markets) @@ -23,6 +24,7 @@ dependencies { /* Compose */ implementation(deps.compose.coil) implementation(deps.compose.foundation) + implementation(deps.compose.material) implementation(deps.compose.material3) implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) @@ -37,6 +39,7 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) implementation(deps.timber) + implementation(deps.decompose.ext.compose) /* Core */ implementation(projects.core.decompose) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt new file mode 100644 index 0000000000..a166e55e22 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt @@ -0,0 +1,164 @@ +package com.tangem.features.markets + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.* +import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState +import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.value.Value +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.api.toSerializable +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsEntryComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + private val marketsEntryChildFactory: MarketsEntryChildFactory, +) : MarketsEntryComponent, AppComponentContext by context { + + private val stackNavigation = StackNavigation() + + val stack: Value> = childStack( + key = "main", + source = stackNavigation, + serializer = MarketsEntryChildFactory.Child.serializer(), + initialConfiguration = MarketsEntryChildFactory.Child.TokenList, + handleBackButton = true, + childFactory = { configuration, componentContext -> + marketsEntryChildFactory.createChild( + child = configuration, + appComponentContext = childByContext(componentContext), + onTokenSelected = ::marketsListTokenSelected, + onDetailsBack = ::onDetailsBack, + ) + }, + ) + + @Suppress("LongMethod") + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + val primary = TangemTheme.colors.background.primary + val secondary = TangemTheme.colors.background.secondary + val backgroundColor = remember { Animatable(primary) } + val stackState = stack.subscribeAsState() + + LocalMainBottomSheetColor.current.value = backgroundColor.value + + Children( + stack = stackState.value, + animation = stackAnimation(slide()), + ) { + when (it.configuration) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + (it.instance as MarketsTokenListComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + } + } + + // order of LaunchedEffects is important here + + val activeChild = stackState.value.active.configuration + + LaunchedEffect(activeChild) { + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 500), + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 500), + ) + } + } + } + + LaunchedEffect(bottomSheetState.value) { + if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) { + when (bottomSheetState.value) { + BottomSheetState.EXPANDED -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 100), + ) + } + BottomSheetState.COLLAPSED -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 100), + ) + } + } + } + } + + LaunchedEffect(primary, secondary) { + if (backgroundColor.isRunning) return@LaunchedEffect + + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.snapTo(secondary) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.snapTo(primary) + } + } + } + } + + @OptIn(ExperimentalDecomposeApi::class) + private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) { + stackNavigation.pushNew( + configuration = MarketsEntryChildFactory.Child.TokenDetails( + params = MarketsTokenDetailsComponent.Params( + token = token.toSerializable(), + appCurrency = appCurrency, + ), + ), + ) + } + + private fun onDetailsBack() { + stackNavigation.popWhile { it != MarketsEntryChildFactory.Child.TokenList } + } + + @AssistedFactory + interface Factory : MarketsEntryComponent.Factory { + override fun create(context: AppComponentContext): DefaultMarketsEntryComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt new file mode 100644 index 0000000000..0db378d0f5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt @@ -0,0 +1,52 @@ +package com.tangem.features.markets + +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import kotlinx.serialization.Serializable +import javax.inject.Inject + +internal class MarketsEntryChildFactory @Inject constructor( + private val tokenListComponentFactory: MarketsTokenListComponent.Factory, + private val tokenDetailsComponentFactory: MarketsTokenDetailsComponent.Factory, +) { + + @Serializable + @Immutable + sealed interface Child { + + @Serializable + @Immutable + data object TokenList : Child + + @Serializable + @Immutable + data class TokenDetails(val params: MarketsTokenDetailsComponent.Params) : Child + } + + fun createChild( + child: Child, + appComponentContext: AppComponentContext, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + onDetailsBack: () -> Unit, + ): Any { + return when (child) { + is Child.TokenDetails -> { + tokenDetailsComponentFactory.create( + context = appComponentContext, + params = child.params, + onBack = onDetailsBack, + ) + } + is Child.TokenList -> { + tokenListComponentFactory.create( + context = appComponentContext, + onTokenSelected = onTokenSelected, + ) + } + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt deleted file mode 100644 index c5329d71a3..0000000000 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/component/impl/DefaultMarketsListComponent.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.tangem.features.markets.component.impl - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.component.MarketsListComponent -import com.tangem.features.markets.model.MarketsListModel -import com.tangem.features.markets.ui.MarketsList -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultMarketsListComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, -) : MarketsListComponent, AppComponentContext by context { - - private val model: MarketsListModel = getOrCreateModel() - - @Composable - override fun BottomSheetContent( - bottomSheetState: State, - onHeaderSizeChange: (Dp) -> Unit, - modifier: Modifier, - ) { - val state by model.state.collectAsStateWithLifecycle() - val bsState by bottomSheetState - - LaunchedEffect(bsState) { - model.containerBottomSheetState.value = bsState - } - - MarketsList( - modifier = modifier, - state = state, - onHeaderSizeChange = onHeaderSizeChange, - bottomSheetState = bsState, - ) - } - - @AssistedFactory - interface Factory : MarketsListComponent.Factory { - override fun create(context: AppComponentContext): DefaultMarketsListComponent - } -} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt new file mode 100644 index 0000000000..a706e5850e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt @@ -0,0 +1,32 @@ +package com.tangem.features.markets.details.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.markets.component.BottomSheetState +import kotlinx.serialization.Serializable + +@Stable +interface MarketsTokenDetailsComponent { + + @Serializable + data class Params( + val token: TokenMarketSerializable, + val appCurrency: AppCurrency, + ) + + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory { + fun create(context: AppComponentContext, params: Params, onBack: () -> Unit): MarketsTokenDetailsComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt new file mode 100644 index 0000000000..a1aa6b178c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/TokenMarketSerializable.kt @@ -0,0 +1,40 @@ +package com.tangem.features.markets.details.api + +import com.tangem.domain.core.serialization.SerializedBigDecimal +import com.tangem.domain.markets.TokenMarket +import kotlinx.serialization.Serializable + +@Serializable +data class TokenMarketSerializable( + val id: String, + val name: String, + val symbol: String, + val marketCap: SerializedBigDecimal?, + val tokenQuotes: Quotes, + val imageUrl: String, +) { + + @Serializable + data class Quotes( + val currentPrice: SerializedBigDecimal, + val h24Percent: SerializedBigDecimal, + val weekPercent: SerializedBigDecimal, + val monthPercent: SerializedBigDecimal, + ) +} + +fun TokenMarket.toSerializable(): TokenMarketSerializable { + return TokenMarketSerializable( + id = id, + name = name, + symbol = symbol, + marketCap = marketCap, + tokenQuotes = TokenMarketSerializable.Quotes( + currentPrice = tokenQuotesShort.currentPrice, + h24Percent = tokenQuotesShort.h24ChangePercent, + weekPercent = tokenQuotesShort.weekChangePercent, + monthPercent = tokenQuotesShort.monthChangePercent, + ), + imageUrl = imageUrlLarge, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt new file mode 100644 index 0000000000..402356487a --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -0,0 +1,63 @@ +package com.tangem.features.markets.details.impl + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel +import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Stable +internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: MarketsTokenDetailsComponent.Params, + @Assisted private val onBack: () -> Unit, +) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent { + + private val model: MarketsTokenDetailsModel = getOrCreateModel(params) + + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + + val state by model.state.collectAsStateWithLifecycle() + val bsState by bottomSheetState + + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState + } + + MarketsTokenDetailsContent( + state = state, + onBackClick = onBack, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) + } + + @AssistedFactory + interface Factory : MarketsTokenDetailsComponent.Factory { + override fun create( + context: AppComponentContext, + params: MarketsTokenDetailsComponent.Params, + onBack: () -> Unit, + ): DefaultMarketsTokenDetailsComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..eb07f7d3b3 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.details.impl.di + +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.impl.DefaultMarketsTokenDetailsComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindMarketsTokenDetailsComponent( + factory: DefaultMarketsTokenDetailsComponent.Factory, + ): MarketsTokenDetailsComponent.Factory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ModelModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ModelModule.kt new file mode 100644 index 0000000000..83e26ff055 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/di/ModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.details.impl.di + +import com.tangem.core.decompose.di.DecomposeComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(DecomposeComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(MarketsTokenDetailsModel::class) + fun provideMarketsTokenDetailsModel(model: MarketsTokenDetailsModel): Model +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt new file mode 100644 index 0000000000..2a40b0c83e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -0,0 +1,501 @@ +package com.tangem.features.markets.details.impl.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.ui.charts.state.* +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.* +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent +import com.tangem.features.markets.details.impl.model.converters.DescriptionConverter +import com.tangem.features.markets.details.impl.model.converters.TokenMarketInfoConverter +import com.tangem.features.markets.details.impl.model.formatter.* +import com.tangem.features.markets.details.impl.model.formatter.formatAsPrice +import com.tangem.features.markets.details.impl.model.formatter.getChangePercentBetween +import com.tangem.features.markets.details.impl.model.formatter.getPercentByInterval +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.joda.time.DateTime +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LargeClass", "LongParameterList") +@Stable +internal class MarketsTokenDetailsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, + private val getTokenQuotesUseCase: GetTokenQuotesUseCase, + private val urlOpener: UrlOpener, +) : Model() { + + private var quotesJob = JobHolder() + private val params = paramsContainer.require() + + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = params.appCurrency, + ) + + private val infoConverter = TokenMarketInfoConverter( + appCurrency = Provider { currentAppCurrency.value }, + onInfoClick = { + showInfoBottomSheet(it) + }, + onLinkClick = { + urlOpener.openUrl(it.url) + }, + ) + private val descriptionConverter = DescriptionConverter( + onReadModeClicked = { + showInfoBottomSheet(it) + }, + ) + + private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) { + chartData = MarketChartData.NoData.Loading + + updateLook { + val percentChangeType = params.token.tokenQuotes.h24Percent.percentChangeType() + + it.copy( + type = percentChangeType.toChartType(), + xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(PriceChangeInterval.H24), + yAxisFormatter = { value -> + BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = value, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = "", + ) + }, + ) + } + } + + private val currentQuotes = MutableStateFlow( + TokenQuotes( + currentPrice = params.token.tokenQuotes.currentPrice, + h24ChangePercent = params.token.tokenQuotes.h24Percent, + weekChangePercent = params.token.tokenQuotes.weekPercent, + monthChangePercent = params.token.tokenQuotes.monthPercent, + m3ChangePercent = null, + m6ChangePercent = null, + yearChangePercent = null, + allTimeChangePercent = null, + ), + ) + + private var lastUpdatedTimestamp: Long = DateTime.now().millis + + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + val isVisibleOnScreen = MutableStateFlow(false) + + val state = MutableStateFlow( + MarketsTokenDetailsUM( + tokenName = params.token.name, + priceText = BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = params.token.tokenQuotes.currentPrice, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ), + dateTimeText = resourceReference(R.string.common_today), + priceChangePercentText = BigDecimalFormatter.formatPercent( + percent = params.token.tokenQuotes.h24Percent, + useAbsoluteValue = true, + ), + priceChangeType = if (params.token.tokenQuotes.h24Percent < BigDecimal.ZERO) { + PriceChangeType.DOWN + } else { + PriceChangeType.UP + }, + iconUrl = params.token.imageUrl, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = chartDataProducer, + chartLook = MarketChartLook(), + onLoadRetryClick = ::onLoadRetryClicked, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = ::onMarkerPointSelected, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = ::onSelectedIntervalChange, + markerSet = false, + body = MarketsTokenDetailsUM.Body.Loading, + triggerPriceChange = consumedEvent(), + infoBottomSheet = TangemBottomSheetConfig( + isShow = false, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + ), + ) + + private val loadChartJobHolder = JobHolder() + + init { + // reload screen if currency changed + modelScope.launch { + currentAppCurrency + .filter { it != params.appCurrency } + .collectLatest { + initialLoad() + } + } + + initialLoad() + } + + private fun initialLoad() { + loadInfo() + loadChart(state.value.selectedInterval) + modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS) + } + + private fun loadQuotes() { + modelScope.launch { + val result = getTokenQuotesUseCase( + tokenId = params.token.id, + appCurrency = currentAppCurrency.value, + ) + + result.onRight { res -> + updateQuotes(res) + } + } + } + + private fun loadChart(interval: PriceChangeInterval) { + modelScope.launch { + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + chartDataProducer.runTransactionSuspend { + chartData = MarketChartData.NoData.Loading + } + + val chart = getTokenPriceChartUseCase.invoke( + appCurrency = currentAppCurrency.value, + interval = interval, + tokenId = params.token.id, + ) + + state.update { + it.copy( + selectedInterval = interval, + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + val xAxisFormatter = MarketsDateTimeFormatters.getChartXFormatterByInterval(state.value.selectedInterval) + + chart.onRight { + chartDataProducer.runTransactionSuspend { + chartData = MarketChartData.Data( + x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + y = it.priceY.toImmutableList(), + ) + + updateLook { + it.copy( + xAxisFormatter = xAxisFormatter, + type = state.value.priceChangeType.toChartType(), + ) + } + } + + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.DATA, + ), + ) + } + }.onLeft { + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.ERROR, + ), + body = if (it.body is MarketsTokenDetailsUM.Body.Error) { + MarketsTokenDetailsUM.Body.Nothing + } else { + it.body + }, + ) + } + } + }.saveIn(loadChartJobHolder) + } + + private fun loadInfo() { + state.update { + it.copy( + body = MarketsTokenDetailsUM.Body.Loading, + ) + } + + modelScope.launch { + val tokenMarketInfo = getTokenMarketInfoUseCase( + appCurrency = currentAppCurrency.value, + tokenId = params.token.id, + ) + + tokenMarketInfo.fold( + ifRight = { result -> + currentQuotes.value = result.quotes + val percent = result.quotes.getPercentByInterval(interval = state.value.selectedInterval) + state.update { + it.copy( + priceText = result.quotes.currentPrice.formatAsPrice(currentAppCurrency.value), + priceChangePercentText = result.quotes.getFormattedPercentByInterval( + interval = it.selectedInterval, + ), + priceChangeType = percent.percentChangeType(), + body = MarketsTokenDetailsUM.Body.Content( + description = descriptionConverter.convert(result), + infoBlocks = infoConverter.convert(result), + ), + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + }, + ifLeft = { + state.update { + if (it.chartState.status == MarketsTokenDetailsUM.ChartState.Status.DATA) { + it.copy( + body = MarketsTokenDetailsUM.Body.Error( + onLoadRetryClick = ::onLoadRetryClicked, + ), + ) + } else { + it.copy( + body = MarketsTokenDetailsUM.Body.Nothing, + ) + } + } + }, + ) + } + } + + private suspend fun updateQuotes(newQuotes: TokenQuotes) { + val triggerPriceChangeType = getFormattedPriceChange( + currentPrice = currentQuotes.value.currentPrice, + updatedPrice = newQuotes.currentPrice, + ) + val trigger = if (triggerPriceChangeType != PriceChangeType.NEUTRAL) { + triggeredEvent( + data = triggerPriceChangeType, + onConsume = { + state.update { it.copy(triggerPriceChange = consumedEvent()) } + }, + ) + } else { + consumedEvent() + } + + val percent = newQuotes.getPercentByInterval(interval = state.value.selectedInterval) + val priceChangeType = percent.percentChangeType() + + // wait until marker is removed + state.first { it.markerSet.not() } + + currentQuotes.value = newQuotes + lastUpdatedTimestamp = DateTime.now().millis + + state.update { stateToUpdate -> + stateToUpdate.copy( + priceText = newQuotes.currentPrice.formatAsPrice(currentAppCurrency.value), + priceChangePercentText = newQuotes.getFormattedPercentByInterval( + interval = stateToUpdate.selectedInterval, + ), + priceChangeType = priceChangeType, + triggerPriceChange = trigger, + dateTimeText = getDefaultDateTimeString(stateToUpdate.selectedInterval), + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + } + + private fun onSelectedIntervalChange(interval: PriceChangeInterval) { + if (state.value.selectedInterval == interval) return + + val quotes = currentQuotes.value + val priceChangePercent = quotes.getFormattedPercentByInterval(interval) + + state.update { + it.copy( + priceChangePercentText = priceChangePercent, + selectedInterval = interval, + priceChangeType = quotes.getPercentByInterval(interval)?.percentChangeType() + ?: PriceChangeType.NEUTRAL, + dateTimeText = getDefaultDateTimeString(interval), + ) + } + + loadChart(interval) + + if (priceChangePercent.isEmpty()) { + loadQuotes() + } + } + + @Suppress("MagicNumber") + private fun onMarkerPointSelected(markerTimestamp: BigDecimal?, price: BigDecimal?) { + val currentState = state.value + + val dateTimeText = markerTimestamp?.let { + MarketsDateTimeFormatters.formatDateByIntervalWithMarker( + interval = currentState.selectedInterval, + markerTimestamp = it, + ) + } ?: getDefaultDateTimeString(currentState.selectedInterval) + + val priceText = (price ?: currentQuotes.value.currentPrice).formatAsPrice(currentAppCurrency.value) + + val percent = price?.let { + getChangePercentBetween( + previousPrice = it, + currentPrice = currentQuotes.value.currentPrice, + ) + } ?: currentQuotes.value.getPercentByInterval(currentState.selectedInterval) + + val percentText = percent?.let { + BigDecimalFormatter.formatPercent( + percent = it, + useAbsoluteValue = true, + ) + } ?: "" + + state.update { stateToUpdate -> + stateToUpdate.copy( + markerSet = markerTimestamp != null, + dateTimeText = dateTimeText, + priceText = priceText, + priceChangePercentText = percentText, + priceChangeType = percent.percentChangeType(), + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + } + + private fun showInfoBottomSheet(content: InfoBottomSheetContent) { + state.update { stateToUpdate -> + stateToUpdate.copy( + infoBottomSheet = stateToUpdate.infoBottomSheet.copy( + isShow = true, + onDismissRequest = ::hideInfoBottomSheet, + content = content, + ), + ) + } + } + + private fun hideInfoBottomSheet() { + state.update { stateToUpdate -> + stateToUpdate.copy( + infoBottomSheet = stateToUpdate.infoBottomSheet.copy( + isShow = false, + ), + ) + } + } + + private fun onLoadRetryClicked() { + val currentState = state.value + + if (currentState.chartState.status == MarketsTokenDetailsUM.ChartState.Status.ERROR) { + loadChart(currentState.selectedInterval) + } + + if (currentState.body is MarketsTokenDetailsUM.Body.Error || + currentState.body is MarketsTokenDetailsUM.Body.Nothing + ) { + loadInfo() + modelScope.loadQuotesWithTimer(QUOTES_UPDATE_INTERVAL_MILLIS) + } + } + + private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { + launch { + while (true) { + delay(timeMillis) + // Update quotes only when the container bottom sheet is in the expanded state + containerBottomSheetState.first { it == BottomSheetState.EXPANDED } + // and is visible on the screen + isVisibleOnScreen.first { it } + + loadQuotes() + } + }.saveIn(quotesJob) + } + + private fun getDefaultDateTimeString(interval: PriceChangeInterval): TextReference { + return MarketsDateTimeFormatters.formatDateByInterval( + interval = interval, + startTimestamp = MarketsDateTimeFormatters.getStartTimestampByInterval( + interval = interval, + currentTimestamp = lastUpdatedTimestamp, + ), + ) + } + + private companion object { + const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt new file mode 100644 index 0000000000..40d76daa99 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/DescriptionConverter.kt @@ -0,0 +1,41 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.converter.Converter + +@Stable +internal class DescriptionConverter( + val onReadModeClicked: (InfoBottomSheetContent) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.Description? { + return value.shortDescription?.let { desc -> + MarketsTokenDetailsUM.Description( + shortDescription = stringReference(desc), + fullDescription = value.fullDescription?.let { fullDescription -> + stringReference(fullDescription) + }, + onReadMoreClick = { + onReadModeClicked( + InfoBottomSheetContent( + title = resourceReference( + R.string.markets_token_details_about_token_title, + wrappedList( + value.name, + ), + ), + body = stringReference(value.fullDescription ?: ""), + ), + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt new file mode 100644 index 0000000000..9bbcff51d9 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt @@ -0,0 +1,153 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.InsightsUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Stable +internal class InsightsConverter( + private val appCurrency: Provider, + private val onInfoClick: (InfoBottomSheetContent) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo.Insights): InsightsUM { + return with(value) { + InsightsUM( + h24Info = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.day, + holdersChange = holdersChange?.day, + liquidityChange = liquidityChange?.day, + buyPressureChange = buyPressureChange?.day, + ), + weekInfo = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.week, + holdersChange = holdersChange?.week, + liquidityChange = liquidityChange?.week, + buyPressureChange = buyPressureChange?.week, + ), + monthInfo = createInfoPointList( + experiencedBuyerChange = experiencedBuyerChange?.month, + holdersChange = holdersChange?.month, + liquidityChange = liquidityChange?.month, + buyPressureChange = buyPressureChange?.month, + ), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_insights), + body = stringReference("//TODO"), + ), + ) + }, + ) + } + } + + private fun createInfoPointList( + experiencedBuyerChange: BigDecimal?, + holdersChange: BigDecimal?, + liquidityChange: BigDecimal?, + buyPressureChange: BigDecimal?, + ): ImmutableList { + return persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = experiencedBuyerChange.convertChange(), + change = experiencedBuyerChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + body = resourceReference(R.string.markets_token_details_experienced_buyers_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = buyPressureChange.convertChange(isFiatValue = true), + change = buyPressureChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_buy_pressure), + body = resourceReference(R.string.markets_token_details_buy_pressure_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = holdersChange.convertChange(), + change = holdersChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_holders), + body = resourceReference(R.string.markets_token_details_holders_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = liquidityChange.convertChange(), + change = liquidityChange.changeType(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_liquidity), + body = resourceReference(R.string.markets_token_details_liquidity_description), + ), + ) + }, + ), + ) + } + + private fun BigDecimal?.changeType(): InfoPointUM.ChangeType? { + return when { + this == null -> null + this > BigDecimal.ZERO -> InfoPointUM.ChangeType.UP + this < BigDecimal.ZERO -> InfoPointUM.ChangeType.DOWN + else -> null + } + } + + private fun BigDecimal?.convertChange(isFiatValue: Boolean = false): String { + if (this == null) return StringsSigns.DASH_SIGN + + val value = if (isFiatValue) { + val currency = appCurrency() + BigDecimalFormatter.formatCompactFiatAmount( + amount = this.abs(), + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ) + } else { + BigDecimalFormatter.formatCompactAmount(amount = this.abs()) + } + + val spacing = if (isFiatValue) " " else "" + + return when { + this > BigDecimal.ZERO -> StringsSigns.PLUS + spacing + value + this < BigDecimal.ZERO -> StringsSigns.MINUS + spacing + value + this == BigDecimal.ZERO -> value + else -> StringsSigns.DASH_SIGN + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt new file mode 100644 index 0000000000..1b571e168f --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/LinksConverter.kt @@ -0,0 +1,49 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.LinksUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList + +@Stable +internal class LinksConverter( + private val onLinkClick: (LinksUM.Link) -> Unit, +) : Converter { + + override fun convert(value: TokenMarketInfo.Links): LinksUM { + return LinksUM( + officialLinks = value.officialLinks?.map { it.convert() }.orEmpty().toImmutableList(), + social = value.social?.map { it.convert() }.orEmpty().toImmutableList(), + repository = value.repository?.map { it.convert() }.orEmpty().toImmutableList(), + blockchainSite = value.blockchainSite?.map { it.convert() }.orEmpty().toImmutableList(), + onLinkClick = onLinkClick, + ) + } + + private fun TokenMarketInfo.Link.convert(): LinksUM.Link { + return LinksUM.Link( + title = stringReference(title), + iconRes = getIconById(id), + url = link, + ) + } + + private fun getIconById(id: String?): Int { + return when (id) { + "linkedin" -> R.drawable.ic_linkedin_24 + "discord" -> R.drawable.ic_discord_24 + "youtube" -> R.drawable.ic_youtube_24 + "telegram" -> R.drawable.ic_telegram_24 + "github" -> R.drawable.ic_github_24 + "twitter" -> R.drawable.ic_twitter_24 + "facebook" -> R.drawable.ic_facebook_24 + "reddit" -> R.drawable.ic_reddit_24 + "instagram" -> R.drawable.ic_instagram_24 + "whitepaper" -> R.drawable.ic_doc_24 + else -> R.drawable.ic_arrow_top_right_24 + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt new file mode 100644 index 0000000000..4bf9f1e111 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt @@ -0,0 +1,135 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.MetricsUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +@Stable +internal class MetricsConverter( + private val appCurrency: Provider, + private val onInfoClick: (InfoBottomSheetContent) -> Unit, +) : Converter { + + @Suppress("LongMethod") + override fun convert(value: TokenMarketInfo.Metrics): MetricsUM { + return with(value) { + MetricsUM( + metrics = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_capitalization), + value = marketCap.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_market_capitalization), + body = resourceReference( + R.string.markets_token_details_market_capitalization_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_rating), + value = marketRating?.toString() ?: StringsSigns.DASH_SIGN, + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_market_rating), + body = resourceReference(R.string.markets_token_details_market_rating_description), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_trading_volume), + value = volume24h.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_trading_volume), + body = resourceReference( + R.string.markets_token_details_trading_volume_24h_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + value = fullyDilutedValuation.formatAmount(), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + body = resourceReference( + R.string.markets_token_details_fully_diluted_valuation_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_circulating_supply), + value = circulatingSupply.formatAmount(crypto = true), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_circulating_supply), + body = resourceReference( + R.string.markets_token_details_circulating_supply_description, + ), + ), + ) + }, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_total_supply), + value = totalSupply.formatAmount(crypto = true), + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_total_supply), + body = resourceReference(R.string.markets_token_details_total_supply_description), + ), + ) + }, + ), + ), + ) + } + } + + private fun BigDecimal?.formatAmount(crypto: Boolean = false): String { + return if (crypto) { + val formatter = NumberFormat.getNumberInstance(Locale.getDefault()).apply { + maximumFractionDigits = 0 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + formatter.format(this) + } else { + val currency = appCurrency() + BigDecimalFormatter.formatFiatAmount( + fiatAmount = this, + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + decimals = 0, + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt new file mode 100644 index 0000000000..0ccf01cde7 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/PricePerformanceConverter.kt @@ -0,0 +1,59 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM +import com.tangem.utils.Provider +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import java.math.BigDecimal +import java.math.RoundingMode + +@Stable +internal class PricePerformanceConverter( + private val appCurrency: Provider, +) : Converter { + + override fun convert(value: TokenMarketInfo.PricePerformance): PricePerformanceUM { + return PricePerformanceUM( + h24 = value.day.convert(), + month = value.month.convert(), + all = value.allTime.convert(), + ) + } + + private fun TokenMarketInfo.Range?.convert(): PricePerformanceUM.Value { + if (this == null) { + return PricePerformanceUM.Value( + low = StringsSigns.DASH_SIGN, + high = StringsSigns.DASH_SIGN, + indicatorFraction = 0f, + ) + } + + return PricePerformanceUM.Value( + low = low.convert(), + high = high.convert(), + indicatorFraction = calculateFraction(), + ) + } + + private fun BigDecimal?.convert(): String { + val currency = appCurrency() + + return BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = this, + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ) + } + + private fun TokenMarketInfo.Range.calculateFraction(): Float { + if (low == null || high == null || low == BigDecimal.ZERO) return 0f + return (high!! - low!!).divide(low!!, RoundingMode.HALF_UP) + .setScale(2, RoundingMode.HALF_UP) + .toFloat().coerceAtMost(1f) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt new file mode 100644 index 0000000000..302aeb7258 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/SecurityScoreConverter.kt @@ -0,0 +1,35 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM +import com.tangem.features.markets.impl.R +import com.tangem.utils.converter.Converter + +// TODO implement when backend is ready +@Stable +internal class SecurityScoreConverter( + private val onInfoClick: (InfoBottomSheetContent) -> Unit, +) : Converter { + + override fun convert(value: Unit): SecurityScoreUM { + return with(value) { + SecurityScoreUM( + score = 4.7f, + description = "Based on 3 ratings", + onInfoClick = { + onInfoClick( + InfoBottomSheetContent( + title = resourceReference(R.string.markets_token_details_security_score), + body = stringReference("markets_token_details_security_score_description"), + // FIXME + // resourceReference(R.string.markets_token_details_security_score_description) + ), + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt new file mode 100644 index 0000000000..f03cbff9c4 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/TokenMarketInfoConverter.kt @@ -0,0 +1,34 @@ +package com.tangem.features.markets.details.impl.model.converters + +import androidx.compose.runtime.Stable +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent +import com.tangem.features.markets.details.impl.ui.state.LinksUM +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.utils.Provider +import com.tangem.utils.converter.Converter + +@Stable +internal class TokenMarketInfoConverter( + appCurrency: Provider, + onInfoClick: (InfoBottomSheetContent) -> Unit, + onLinkClick: (LinksUM.Link) -> Unit, +) : Converter { + + private val insightsConverter = InsightsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick) + private val securityScoreConverter = SecurityScoreConverter(onInfoClick = onInfoClick) + private val metricsConverter = MetricsConverter(appCurrency = appCurrency, onInfoClick = onInfoClick) + private val pricePerformanceConverter = PricePerformanceConverter(appCurrency = appCurrency) + private val linksConverter = LinksConverter(onLinkClick = onLinkClick) + + override fun convert(value: TokenMarketInfo): MarketsTokenDetailsUM.InformationBlocks { + return MarketsTokenDetailsUM.InformationBlocks( + insights = value.insights?.let { insightsConverter.convert(it) }, + securityScore = securityScoreConverter.convert(Unit), + metrics = value.metrics?.let { metricsConverter.convert(it) }, + pricePerformance = value.pricePerformance?.let { pricePerformanceConverter.convert(it) }, + links = value.links?.let { linksConverter.convert(it) }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt new file mode 100644 index 0000000000..f164289fe6 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/Formatters.kt @@ -0,0 +1,99 @@ +package com.tangem.features.markets.details.impl.model.formatter + +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.domain.markets.TokenQuotes +import java.math.BigDecimal +import java.math.RoundingMode + +internal fun BigDecimal.formatAsPrice(currency: AppCurrency): String { + return BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = this, + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ) +} + +internal fun TokenQuotes.getFormattedPercentByInterval(interval: PriceChangeInterval): String { + val percent = when (interval) { + PriceChangeInterval.H24 -> h24ChangePercent + PriceChangeInterval.WEEK -> weekChangePercent + PriceChangeInterval.MONTH -> monthChangePercent + PriceChangeInterval.MONTH3 -> m3ChangePercent + PriceChangeInterval.MONTH6 -> m6ChangePercent + PriceChangeInterval.YEAR -> yearChangePercent + PriceChangeInterval.ALL_TIME -> allTimeChangePercent + } + + return percent?.let { + BigDecimalFormatter.formatPercent( + percent = it, + useAbsoluteValue = true, + ) + } ?: "" +} + +internal fun TokenQuotes.getPercentByInterval(interval: PriceChangeInterval): BigDecimal? { + return when (interval) { + PriceChangeInterval.H24 -> h24ChangePercent + PriceChangeInterval.WEEK -> weekChangePercent + PriceChangeInterval.MONTH -> monthChangePercent + PriceChangeInterval.MONTH3 -> m3ChangePercent + PriceChangeInterval.MONTH6 -> m6ChangePercent + PriceChangeInterval.YEAR -> yearChangePercent + PriceChangeInterval.ALL_TIME -> allTimeChangePercent + } +} + +internal fun BigDecimal?.percentChangeType(): PriceChangeType { + return when { + this == null -> PriceChangeType.NEUTRAL + this > BigDecimal.ZERO -> PriceChangeType.UP + this < BigDecimal.ZERO -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } +} + +@Suppress("MagicNumber") +internal fun getChangePercentBetween(currentPrice: BigDecimal, previousPrice: BigDecimal): BigDecimal { + return if (previousPrice == BigDecimal.ZERO) { + BigDecimal.ZERO + } else { + currentPrice.subtract(previousPrice).divide(previousPrice, 4, RoundingMode.HALF_UP) + } +} + +internal fun getFormattedPriceChange(currentPrice: BigDecimal, updatedPrice: BigDecimal): PriceChangeType { + val updatedPriceDecimals = BigDecimalFormatter.getProperFiatPriceDecimals(updatedPrice) + + val current = currentPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP) + val updated = updatedPrice.setScale(updatedPriceDecimals, RoundingMode.HALF_UP) + + return when { + updated > current -> PriceChangeType.UP + updated < current -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } +} + +internal fun PriceChangeType.toChartType(): MarketChartLook.Type { + return when (this) { + PriceChangeType.UP -> MarketChartLook.Type.Growing + PriceChangeType.DOWN -> MarketChartLook.Type.Falling + PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral + } +} + +@Suppress("MagicNumber") +internal fun getChartTypeByPercent(percent: BigDecimal?): MarketChartLook.Type { + val scaled = percent?.setScale(4, RoundingMode.HALF_UP) + return when { + scaled == null -> return MarketChartLook.Type.Neutral + scaled > BigDecimal.ZERO -> MarketChartLook.Type.Growing + scaled < BigDecimal.ZERO -> MarketChartLook.Type.Falling + else -> MarketChartLook.Type.Neutral + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt new file mode 100644 index 0000000000..acc0ec44fa --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/formatter/MarketsDateTimeFormatters.kt @@ -0,0 +1,130 @@ +package com.tangem.features.markets.details.impl.model.formatter + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.formatAsDateTime +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.impl.R +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import java.math.BigDecimal + +internal object MarketsDateTimeFormatters { + + private const val H24_MILLIS = 24L * 60 * 60 * 1000 + private const val WEEK_MILLIS = 7L * H24_MILLIS + + private val dateTimeMMMFormatter by lazy { + DateTimeFormatters.getBestFormatterBySkeleton("dd MMM Hm") + } + + private val dateFormatter = DateTimeFormatters.dateDDMMYYYY + + internal fun getChartXFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String { + return when (interval) { + PriceChangeInterval.H24 -> { value: BigDecimal -> + value.toLong().formatAsDateTime(DateTimeFormatters.timeFormatter) + } + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd) + } + PriceChangeInterval.YEAR -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateMMMdd) + } + PriceChangeInterval.ALL_TIME -> { value -> + value.toLong().formatAsDateTime(DateTimeFormatters.dateYYYY) + } + } + } + + internal fun formatDateByInterval(interval: PriceChangeInterval, startTimestamp: Long): TextReference { + return when (interval) { + PriceChangeInterval.H24 -> resourceReference(R.string.common_today) + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + startTimestamp.formatAsDateTime(MarketsDateTimeFormatters.dateTimeMMMFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + startTimestamp.formatAsDateTime(MarketsDateTimeFormatters.dateFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.ALL_TIME -> resourceReference(R.string.common_all) + } + } + + internal fun formatDateByIntervalWithMarker( + interval: PriceChangeInterval, + markerTimestamp: BigDecimal, + ): TextReference { + return when (interval) { + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + markerTimestamp.toLong().formatAsDateTime(MarketsDateTimeFormatters.dateTimeMMMFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + PriceChangeInterval.ALL_TIME, + -> { + resourceReference( + R.string.common_range_with_space, + wrappedList( + stringReference( + markerTimestamp.toLong().formatAsDateTime(MarketsDateTimeFormatters.dateFormatter), + ), + resourceReference(R.string.common_now), + ), + ) + } + } + } + + @Suppress("MagicNumber") + fun getStartTimestampByInterval(interval: PriceChangeInterval, currentTimestamp: Long): Long { + return when (interval) { + PriceChangeInterval.H24 -> currentTimestamp - H24_MILLIS + PriceChangeInterval.WEEK -> currentTimestamp - WEEK_MILLIS + PriceChangeInterval.MONTH -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(1).millis + PriceChangeInterval.MONTH3 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(3).millis + PriceChangeInterval.MONTH6 -> DateTime(currentTimestamp, DateTimeZone.UTC).minusMonths(6).millis + PriceChangeInterval.YEAR -> DateTime(currentTimestamp, DateTimeZone.UTC).minusYears(1).millis + PriceChangeInterval.ALL_TIME -> 0 + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt new file mode 100644 index 0000000000..ecfc715abd --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -0,0 +1,293 @@ +package com.tangem.features.markets.details.impl.ui + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.EventEffect +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.disableNestedScroll +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.details.impl.ui.components.InfoBottomSheet +import com.tangem.features.markets.details.impl.ui.components.MarketTokenDetailsChart +import com.tangem.features.markets.details.impl.ui.components.tokenMarketDetailsBody +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Suppress("UnusedPrivateMember") +@Composable +internal fun MarketsTokenDetailsContent( + state: MarketsTokenDetailsUM, + onBackClick: () -> Unit, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + Content( + modifier = modifier, + state = state, + onBackClick = onBackClick, + onHeaderSizeChange = onHeaderSizeChange, + ) + + InfoBottomSheet(config = state.infoBottomSheet) +} + +@Suppress("UnusedPrivateMember") +@Composable +private fun Content( + state: MarketsTokenDetailsUM, + onBackClick: () -> Unit, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = LocalMainBottomSheetColor.current.value + val density = LocalDensity.current + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + + Column( + modifier = modifier + .drawBehind { drawRect(backgroundColor) } + .fillMaxSize(), + ) { + TangemTopAppBar( + modifier = Modifier.onGloballyPositioned { + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } + } + }, + title = state.tokenName, + startButton = TopAppBarButtonUM.Back(onBackClick), + ) + + SpacerH4() + + LazyColumn( + modifier = Modifier.disableNestedScroll(), + contentPadding = PaddingValues(bottom = bottomBarHeight), + ) { + item("header") { + Header( + state = state, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH16() } + item("intervalSelector") { + IntervalSelector( + trendInterval = state.selectedInterval, + onIntervalClick = state.onSelectedIntervalChange, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + item { SpacerH32() } + item( + contentType = "chart", + ) { + MarketTokenDetailsChart( + modifier = Modifier.fillMaxWidth(), + state = state.chartState, + ) + } + item { SpacerH16() } + + tokenMarketDetailsBody( + state = state.body, + ) + } + } +} + +@Composable +private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + Modifier.weight(1f), + ) { + TokenPriceText( + price = state.priceText, + triggerPriceChange = state.triggerPriceChange, + ) + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) { + Text( + text = state.dateTimeText.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + PriceChangeInPercent( + valueInPercent = state.priceChangePercentText, + type = state.priceChangeType, + textStyle = TangemTheme.typography.caption2, + ) + } + } + SpacerW4() + CoinIcon( + modifier = Modifier.requiredSize(TangemTheme.dimens.size48), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + } +} + +@Composable +private fun TokenPriceText( + price: String, + triggerPriceChange: StateEvent, + modifier: Modifier = Modifier, +) { + val growColor = TangemTheme.colors.text.accent + val fallColor = TangemTheme.colors.text.warning + val generalColor = TangemTheme.colors.text.primary1 + + val color = remember { Animatable(generalColor) } + + EventEffect(triggerPriceChange) { + val nextColor = when (it) { + PriceChangeType.UP, + -> growColor + PriceChangeType.DOWN -> fallColor + PriceChangeType.NEUTRAL -> return@EventEffect + } + + color.animateTo(nextColor, snap()) + color.animateTo(generalColor, tween(durationMillis = 500)) + } + + Text( + modifier = modifier, + text = price, + color = color.value, + style = TangemTheme.typography.head, + ) +} + +@Composable +private fun IntervalSelector( + trendInterval: PriceChangeInterval, + onIntervalClick: (PriceChangeInterval) -> Unit, + modifier: Modifier = Modifier, +) { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + PriceChangeInterval.ALL_TIME, + ), + color = TangemTheme.colors.button.secondary, + initialSelectedItem = trendInterval, + onClick = onIntervalClick, + modifier = modifier, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding( + vertical = TangemTheme.dimens.spacing4, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +@Composable +fun PriceChangeInterval.getText(): TextReference { + return when (this) { + PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title) + PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title) + PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title) + PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title) + PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title) + PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title) + PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title) + } +} + +@Preview +@Composable +private fun Preview() { + TangemThemePreview { + Content( + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + state = MarketsTokenDetailsUM( + tokenName = "Token Name", + priceText = "$0.00000000324", + dateTimeText = stringReference("Today"), + priceChangePercentText = "52.00%", + iconUrl = "", + priceChangeType = PriceChangeType.UP, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = MarketChartDataProducer.build { }, + chartLook = MarketChartLook(), + onLoadRetryClick = {}, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = { _, _ -> }, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = { }, + body = MarketsTokenDetailsUM.Body.Loading, + infoBottomSheet = TangemBottomSheetConfig( + isShow = false, + onDismissRequest = {}, + content = TangemBottomSheetConfigContent.Empty, + ), + markerSet = false, + triggerPriceChange = consumedEvent(), + ), + onHeaderSizeChange = {}, + onBackClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt new file mode 100644 index 0000000000..e895b421f5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/Description.kt @@ -0,0 +1,112 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.impl.R + +@Composable +internal fun Description( + description: TextReference, + hasFullDescription: Boolean, + onReadMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (hasFullDescription) { + val text = buildAnnotatedString { + withStyle(SpanStyle(color = TangemTheme.colors.text.secondary)) { + append(description.resolveReference()) + } + withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { + append(" " + stringResource(R.string.common_read_more)) + } + } + + ClickableText( + modifier = modifier, + text = text, + style = TangemTheme.typography.body2, + ) { + text.spanStyles.getOrNull(1)?.let { spanStyle -> + if (it in spanStyle.start..spanStyle.end) { + onReadMoreClick() + } + } + } + } else { + Text( + modifier = modifier, + text = description.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +internal fun DescriptionPlaceholder(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.8f), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } +} + +@Preview +@Composable +private fun ContentPreview() { + TangemThemePreview { + Description( + description = stringReference( + "XRP (XRP) is a cryptocurrency launched in January 2009, where the first " + + "genesis block was mined on 9th January 2009", + ), + hasFullDescription = true, + onReadMoreClick = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { + ContentPreview() + }, + shimmerContent = { + DescriptionPlaceholder() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt new file mode 100644 index 0000000000..0d44a41f91 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt @@ -0,0 +1,47 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent + +@Composable +internal fun InfoBottomSheet(config: TangemBottomSheetConfig) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + TangemBottomSheet( + config = config, + skipPartiallyExpanded = false, + addBottomInsets = false, + title = { + TangemBottomSheetTitle(title = it.title) + }, + content = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = TangemTheme.dimens.spacing28), + ) { + Text( + text = it.body.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + SpacerH(bottomBarHeight) + } + }, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoPoint.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoPoint.kt new file mode 100644 index 0000000000..3ca286968f --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoPoint.kt @@ -0,0 +1,186 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM + +@Composable +internal fun InfoPoint(infoPointUM: InfoPointUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + horizontalAlignment = Alignment.Start, + ) { + if (infoPointUM.onInfoClick != null) { + TooltipText( + text = infoPointUM.title, + onInfoClick = infoPointUM.onInfoClick, + textStyle = TangemTheme.typography.caption2, + ) + } else { + Text( + text = infoPointUM.title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row { + Text( + text = infoPointUM.value, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + if (infoPointUM.change != null) { + SpacerW4() + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size8) + .align(Alignment.CenterVertically), + imageVector = ImageVector.vectorResource( + id = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> R.drawable.ic_arrow_up_8 + InfoPointUM.ChangeType.DOWN -> R.drawable.ic_arrow_down_8 + }, + ), + tint = when (infoPointUM.change) { + InfoPointUM.ChangeType.UP -> TangemTheme.colors.icon.accent + InfoPointUM.ChangeType.DOWN -> TangemTheme.colors.icon.warning + }, + contentDescription = null, + ) + } + } + } +} + +@Composable +internal fun InfoPointShimmer(modifier: Modifier = Modifier, withTooltip: Boolean = false) { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + horizontalAlignment = Alignment.Start, + ) { + if (withTooltip) { + Box( + modifier = Modifier + .requiredHeight(TangemTheme.dimens.size16) + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.caption2, + textSizeHeight = false, + ) + } + } else { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.caption2, + textSizeHeight = true, + ) + } + TextShimmer( + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + style = TangemTheme.typography.body1, + textSizeHeight = true, + ) + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + Column( + modifier = Modifier + .width(150.dp) + .background(TangemTheme.colors.background.tertiary), + ) { + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000,000", + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.UP, + onInfoClick = { }, + ), + ) + InfoPoint( + infoPointUM = InfoPointUM( + title = stringReference("Market Cap"), + value = "$1,000,000", + change = InfoPointUM.ChangeType.DOWN, + onInfoClick = { }, + ), + ) + } + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewShimmer() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + Column( + modifier = Modifier + .width(150.dp) + .background(TangemTheme.colors.background.tertiary), + ) { + InfoPointShimmer(modifier = Modifier.fillMaxWidth()) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + } + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt new file mode 100644 index 0000000000..df5dab7cce --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InsightsBlock.kt @@ -0,0 +1,195 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.block.information.GridItems +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.InsightsUM +import com.tangem.features.markets.details.impl.ui.getText +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun InsightsBlock(state: InsightsUM, modifier: Modifier = Modifier) { + var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) } + + InformationBlock( + modifier = modifier, + title = { + TooltipText( + text = resourceReference(R.string.markets_token_details_insights), + textStyle = TangemTheme.typography.subtitle2, + onInfoClick = state.onInfoClick, + ) + }, + action = { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + ), + initialSelectedItem = PriceChangeInterval.H24, + onClick = { currentInterval = it }, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding(vertical = TangemTheme.dimens.spacing4), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } + }, + content = { + val infoPoints = when (currentInterval) { + PriceChangeInterval.H24 -> state.h24Info + PriceChangeInterval.WEEK -> state.weekInfo + PriceChangeInterval.MONTH -> state.monthInfo + else -> state.h24Info + } + + GridItems( + items = infoPoints, + itemContent = { + InfoPoint( + modifier = Modifier.align(Alignment.CenterStart), + infoPointUM = it, + ) + }, + ) + }, + ) +} + +@Composable +internal fun InsightsBlockPlaceholder(modifier: Modifier = Modifier) { + val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() } + val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() } + val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4 + + InformationBlock( + modifier = modifier, + title = { + RectangleShimmer( + modifier = Modifier + .height(headerHeight) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + }, + content = { + GridItems( + items = List(size = 4) { it }.toImmutableList(), + horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + itemContent = { + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + ) + }, + ) + }, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + InsightsBlock( + state = InsightsUM( + h24Info = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000 000", + ), + ), + weekInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000 000", + ), + ), + monthInfo = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_experienced_buyers), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_buy_pressure), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_holders), + value = "1 000", + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_liquidity), + value = "1 000", + ), + ), + onInfoClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { ContentPreview() }, + shimmerContent = { InsightsBlockPlaceholder() }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt new file mode 100644 index 0000000000..ca7e4d4390 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/LinksBlock.kt @@ -0,0 +1,221 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.LinksUM +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun LinksBlock(state: LinksUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResource(id = R.string.markets_token_details_links), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + content = { + Column { + SubBlock( + title = stringResource(id = R.string.markets_token_details_official_links), + links = state.officialLinks, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResource(id = R.string.markets_token_details_social), + links = state.social, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResource(id = R.string.markets_token_details_repository), + links = state.repository, + onLinkClick = state.onLinkClick, + ) + SubBlock( + title = stringResource(id = R.string.markets_token_details_blockchain_site), + links = state.blockchainSite, + onLinkClick = state.onLinkClick, + ) + } + }, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SubBlock( + links: ImmutableList, + onLinkClick: (LinksUM.Link) -> Unit, + modifier: Modifier = Modifier, + lastBlock: Boolean = false, + title: String = "Official links", +) { + if (links.isEmpty()) return + + DividerContainer( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Text( + text = title, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + links.fastForEach { + SecondarySmallButton( + config = SmallButtonConfig( + text = it.title, + onClick = { onLinkClick(it) }, + icon = TangemButtonIconPosition.Start(iconResId = it.iconRes), + ), + ) + } + } + } + } +} + +@Composable +fun LinksBlockPlaceholder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.subtitle2, + ) + }, + content = { + Column { + SubBlockPlaceholder() + SubBlockPlaceholder() + SubBlockPlaceholder(lastBlock = true) + } + }, + ) +} + +@Composable +private fun SubBlockPlaceholder(modifier: Modifier = Modifier, lastBlock: Boolean = false) { + DividerContainer( + modifier = modifier, + showDivider = !lastBlock, + ) { + Column( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + TextShimmer( + modifier = Modifier.width(78.dp), + style = TangemTheme.typography.caption2, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + repeat(times = 3) { + SmallButtonShimmer( + modifier = Modifier.weight(1f), + withIcon = true, + ) + } + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + LinksBlock( + state = LinksUM( + officialLinks = persistentListOf( + LinksUM.Link( + title = stringReference("Website"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = stringReference("Website"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = stringReference("Website"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + social = persistentListOf( + LinksUM.Link( + title = stringReference("Twitter"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + LinksUM.Link( + title = stringReference("Facebook"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + repository = persistentListOf( + LinksUM.Link( + title = stringReference("Github"), + iconRes = R.drawable.ic_plus_24, + url = "https://tangem.com", + ), + ), + blockchainSite = persistentListOf(), + onLinkClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreview() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { LinksBlockPlaceholder() }, + actualContent = { ContentPreview() }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt new file mode 100644 index 0000000000..83bc41555e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -0,0 +1,81 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import com.tangem.common.ui.charts.MarketChart +import com.tangem.common.ui.charts.getMarketChartBottomAxisHeight +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.common.ui.charts.state.rememberMarketChartState +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData + +@Composable +internal fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, modifier: Modifier = Modifier) { + val growingColor = TangemTheme.colors.icon.accent + val fallingColor = TangemTheme.colors.icon.warning + val neutralColor = TangemTheme.colors.icon.informative + + val chartState = rememberMarketChartState( + dataProducer = state.dataProducer, + colorMapper = { + when (it) { + MarketChartLook.Type.Growing -> growingColor + MarketChartLook.Type.Falling -> fallingColor + MarketChartLook.Type.Neutral -> neutralColor + } + }, + onMarkerShown = state.onMarkerPointSelected, + ) + + val backgroundColor = LocalMainBottomSheetColor.current.value + val bottomChartAxisHeight = getMarketChartBottomAxisHeight() + + Box(modifier) { + MarketChart( + modifier = Modifier.fillMaxWidth(), + state = chartState, + ) + + if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) { + Box( + Modifier + .drawBehind { drawRect(backgroundColor) } + .matchParentSize() + .padding(bottom = bottomChartAxisHeight), + ) { + when (state.status) { + MarketsTokenDetailsUM.ChartState.Status.LOADING -> { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size16) + .align(Alignment.Center), + color = TangemTheme.colors.text.accent, + strokeWidth = TangemTheme.dimens.size2, + ) + } + MarketsTokenDetailsUM.ChartState.Status.ERROR -> { + UnableToLoadData( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .align(Alignment.Center), + onRetryClick = state.onLoadRetryClick, + ) + } + else -> {} + } + } + } + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt new file mode 100644 index 0000000000..4825c2ab32 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MetricsBlock.kt @@ -0,0 +1,166 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.TextButton +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.GridItems +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.InfoPointUM +import com.tangem.features.markets.details.impl.ui.state.MetricsUM +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +const val MAX_METRICS_COUNT = 6 + +@Composable +internal fun MetricsBlock(state: MetricsUM, modifier: Modifier = Modifier) { + var expanded by remember { mutableStateOf(false) } + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResource(id = R.string.markets_token_details_metrics), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + action = { + if (state.metrics.size > MAX_METRICS_COUNT) { + ShowLessMoreButton(expanded = expanded, onClick = { expanded = !expanded }) + } + }, + content = { + val metrics = if (expanded) { + state.metrics + } else { + state.metrics.take(MAX_METRICS_COUNT).toImmutableList() + } + + GridItems( + items = metrics, + itemContent = { + InfoPoint(infoPointUM = it) + }, + ) + }, + ) +} + +// TODO make TextButton clickable area smaller and remove paddings for an action in InformationBlock +@Composable +private fun ShowLessMoreButton(expanded: Boolean, onClick: () -> Unit) { + // FIXME add string resources + val text = if (expanded) { + "See less" + } else { + "See more" + } + + TextButton( + text = text, + onClick = onClick, + colors = TangemButtonsDefaults.positiveButtonColors, + textStyle = TangemTheme.typography.body2, + ) +} + +@Composable +internal fun MetricsBlockPlaceholder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + style = TangemTheme.typography.subtitle2, + ) + }, + action = { + Box(Modifier) + }, + content = { + GridItems( + items = List(size = 6) { it }.toImmutableList(), + horizontalArragement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + itemContent = { + InfoPointShimmer( + modifier = Modifier.fillMaxWidth(), + withTooltip = true, + ) + }, + ) + }, + ) +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun BlockPreview() { + TangemThemePreview { + MetricsBlock( + state = MetricsUM( + metrics = persistentListOf( + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_capitalization), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_market_rating), + value = "A", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_trading_volume), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_fully_diluted_valuation), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_circulating_supply), + value = "1.2T", + onInfoClick = {}, + ), + InfoPointUM( + title = resourceReference(R.string.markets_token_details_total_supply), + value = "1.2T", + onInfoClick = {}, + ), + ), + ), + ) + } +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + actualContent = { BlockPreview() }, + shimmerContent = { MetricsBlockPlaceholder() }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt new file mode 100644 index 0000000000..66a09fb533 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/PricePerformanceBlock.kt @@ -0,0 +1,247 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemAnimations +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.details.impl.ui.state.PricePerformanceUM +import com.tangem.features.markets.details.impl.ui.getText +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun PricePerformanceBlock(state: PricePerformanceUM, modifier: Modifier = Modifier) { + var currentInterval by remember { mutableStateOf(PriceChangeInterval.H24) } + + InformationBlock( + modifier = modifier, + title = { + Text( + text = stringResource(id = R.string.markets_token_details_price_performance), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + action = { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.MONTH, + PriceChangeInterval.ALL_TIME, + ), + initialSelectedItem = PriceChangeInterval.H24, + onClick = { currentInterval = it }, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding(vertical = TangemTheme.dimens.spacing4), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } + }, + content = { + val value = when (currentInterval) { + PriceChangeInterval.H24 -> state.h24 + PriceChangeInterval.MONTH -> state.month + PriceChangeInterval.ALL_TIME -> state.all + else -> error("") + } + + Content( + modifier = Modifier.fillMaxWidth(), + state = value, + ) + }, + ) +} + +@Composable +private fun Content(state: PricePerformanceUM.Value, modifier: Modifier = Modifier) { + val animatedIndicatorFraction by TangemAnimations.horizontalIndicatorAsState( + targetFraction = state.indicatorFraction, + ) + + Column( + modifier = modifier + .padding(vertical = TangemTheme.dimens.spacing8), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.markets_token_details_low), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerW8() + Text( + text = stringResource(R.string.markets_token_details_high), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + LinearProgressIndicator( + modifier = Modifier + .height(TangemTheme.dimens.size6) + .fillMaxWidth(), + progress = { animatedIndicatorFraction }, + color = TangemTheme.colors.text.accent, + trackColor = TangemTheme.colors.background.tertiary, + strokeCap = StrokeCap.Round, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = state.low, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + SpacerW8() + Text( + text = state.high, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.End, + ) + } + } +} + +@Composable +internal fun PricePerformanceBlockPlaceholder(modifier: Modifier = Modifier) { + val subtitle2dp = with(LocalDensity.current) { TangemTheme.typography.subtitle2.lineHeight.toDp() } + val caption1dp = with(LocalDensity.current) { TangemTheme.typography.caption1.lineHeight.toDp() } + val headerHeight = maxOf(subtitle2dp, caption1dp) + TangemTheme.dimens.spacing4 + + InformationBlock( + modifier = modifier, + title = { + RectangleShimmer( + modifier = Modifier + .height(headerHeight) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + }, + content = { + Column( + modifier = modifier.padding(vertical = TangemTheme.dimens.spacing8), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextShimmer( + modifier = Modifier.width(35.dp), + style = TangemTheme.typography.caption2, + ) + SpacerW8() + TextShimmer( + modifier = Modifier.width(35.dp), + style = TangemTheme.typography.caption2, + ) + } + RectangleShimmer( + modifier = Modifier + .height(TangemTheme.dimens.size6) + .fillMaxWidth(), + radius = 27.dp, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextShimmer( + modifier = Modifier.width(TangemTheme.dimens.size56), + style = TangemTheme.typography.body1, + ) + SpacerW8() + TextShimmer( + modifier = Modifier.width(TangemTheme.dimens.size56), + style = TangemTheme.typography.body1, + ) + } + } + }, + ) +} + +@Preview +@Preview("Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + PricePerformanceBlock( + modifier = Modifier, + state = PricePerformanceUM( + h24 = PricePerformanceUM.Value( + low = "\$38,5K", + high = "\$58,5K", + indicatorFraction = 0.5f, + ), + month = PricePerformanceUM.Value( + low = "\$500,5K", + high = "\$5800,5K", + indicatorFraction = 0.8f, + ), + all = PricePerformanceUM.Value( + low = "\$58,52", + high = "\$580,5M", + indicatorFraction = 0.2f, + ), + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PlaceholderPreview() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + PricePerformanceBlockPlaceholder() + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBlock.kt new file mode 100644 index 0000000000..66a75660ef --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBlock.kt @@ -0,0 +1,203 @@ +package com.tangem.features.markets.details.impl.ui.components + +import android.content.res.Configuration +import androidx.annotation.FloatRange +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.components.text.TooltipText +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.PreviewShimmerContainer +import com.tangem.features.markets.details.impl.ui.state.SecurityScoreUM +import com.tangem.features.markets.impl.R +import kotlin.math.round + +private const val STARS_COUNT = 5 + +@Composable +internal fun SecurityScoreBlock(state: SecurityScoreUM, modifier: Modifier = Modifier) { + val rounded = state.score.roundTo1decimal() + val percentage = rounded / STARS_COUNT + InformationBlock( + modifier = modifier, + title = { + Column( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + TooltipText( + text = resourceReference(R.string.markets_token_details_security_score), + onInfoClick = state.onInfoClick, + textStyle = TangemTheme.typography.subtitle2, + ) + + Text( + text = state.description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + action = { + Row( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = rounded.toString(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Stars(fraction = percentage) + } + }, + ) +} + +@Suppress("MagicNumber") +@Composable +private fun Stars(@FloatRange(0.0, 1.0) fraction: Float = 0f) { + val grayColor = TangemTheme.colors.icon.inactive + + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(times = 5) { i -> + Box( + modifier = Modifier.size(TangemTheme.dimens.size16), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .requiredSize(13.dp) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithCache { + onDrawWithContent { + val starFraction = ((fraction - i * 0.2) / 0.2).coerceIn(0.0, 1.0) + val starFractionFloat = starFraction + .toFloat() + .roundTo1decimal() + + drawContent() + drawRect( + color = grayColor, + topLeft = Offset(x = size.width * starFractionFloat, y = 0f), + size = Size(size.width * (1 - starFractionFloat), size.height), + blendMode = BlendMode.SrcIn, + ) + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_star_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + } + } +} + +@Suppress("MagicNumber") +private fun Float.roundTo1decimal(): Float { + return round(this * 10) / 10 +} + +@Composable +internal fun SecurityScorePlaceHolder(modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier, + title = { + Column( + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing6), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.subtitle2, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + } + }, + action = { + Box( + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing24, + bottom = TangemTheme.dimens.spacing6, + ), + contentAlignment = Alignment.CenterEnd, + ) { + TextShimmer( + modifier = Modifier.fillMaxWidth(), + style = TangemTheme.typography.body2, + textSizeHeight = true, + ) + RectangleShimmer( + modifier = Modifier + .height(TangemTheme.dimens.size16) + .fillMaxWidth(), + radius = TangemTheme.dimens.radius3, + ) + } + }, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContentPreview() { + TangemThemePreview { + SecurityScoreBlock( + state = SecurityScoreUM( + score = 3.5f, + description = "Based on 3 ratings", + onInfoClick = {}, + ), + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPlaceholder() { + TangemThemePreview { + PreviewShimmerContainer( + shimmerContent = { + SecurityScorePlaceHolder( + modifier = Modifier.fillMaxWidth(), + ) + }, + actualContent = { + ContentPreview() + }, + ) + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt new file mode 100644 index 0000000000..40cc2fc2f5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt @@ -0,0 +1,142 @@ +package com.tangem.features.markets.details.impl.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM +import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData + +internal fun LazyListScope.tokenMarketDetailsBody(state: MarketsTokenDetailsUM.Body) { + when (state) { + MarketsTokenDetailsUM.Body.Loading -> { + loading() + } + is MarketsTokenDetailsUM.Body.Content -> { + if (state.description != null) { + description(state.description) + } + + infoBlocksList(state.infoBlocks) + } + is MarketsTokenDetailsUM.Body.Error -> { + error(state) + } + MarketsTokenDetailsUM.Body.Nothing -> { + // Do nothing + } + } +} + +private fun LazyListScope.error(state: MarketsTokenDetailsUM.Body.Error) { + item("body-error") { + Box(Modifier.fillMaxWidth()) { + UnableToLoadData( + modifier = Modifier + .align(Alignment.Center) + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing40, + ), + onRetryClick = state.onLoadRetryClick, + ) + } + } +} + +private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) { + item("description") { + Description( + modifier = Modifier.blockPaddings(), + description = description.shortDescription, + hasFullDescription = description.fullDescription != null, + onReadMoreClick = description.onReadMoreClick, + ) + } +} + +internal fun LazyListScope.infoBlocksList(state: MarketsTokenDetailsUM.InformationBlocks) { + if (state.insights != null) { + item("insights") { + InsightsBlock( + modifier = Modifier.blockPaddings(), + state = state.insights, + ) + } + } + + if (state.securityScore != null) { + item("securityScore") { + SecurityScoreBlock( + modifier = Modifier.blockPaddings(), + state = state.securityScore, + ) + } + } + + if (state.metrics != null) { + item("metrics") { + MetricsBlock( + modifier = Modifier.blockPaddings(), + state = state.metrics, + ) + } + } + + if (state.pricePerformance != null) { + item("pricePerformance") { + PricePerformanceBlock( + modifier = Modifier.blockPaddings(), + state = state.pricePerformance, + ) + } + } + + if (state.links != null) { + item("links") { + LinksBlock( + modifier = Modifier.blockPaddings(), + state = state.links, + ) + } + } +} + +private fun LazyListScope.loading() { + item("description-loading") { + DescriptionPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("insights-loading") { + InsightsBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("securityScore-loading") { + SecurityScorePlaceHolder(modifier = Modifier.blockPaddings()) + } + + item("metrics-loading") { + MetricsBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("pricePerformance-loading") { + PricePerformanceBlockPlaceholder(modifier = Modifier.blockPaddings()) + } + + item("links-loading") { + LinksBlockPlaceholder(modifier = Modifier.blockPaddings()) + } +} + +@Composable +private fun Modifier.blockPaddings(): Modifier { + return this.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing12, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt new file mode 100644 index 0000000000..858fc017c5 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoBottomSheetContent.kt @@ -0,0 +1,9 @@ +package com.tangem.features.markets.details.impl.ui.state + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference + +internal data class InfoBottomSheetContent( + val title: TextReference, + val body: TextReference, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoPointUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoPointUM.kt new file mode 100644 index 0000000000..383db4e627 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InfoPointUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.markets.details.impl.ui.state + +import com.tangem.core.ui.extensions.TextReference + +internal data class InfoPointUM( + val title: TextReference, + val value: String, + val change: ChangeType? = null, + val onInfoClick: (() -> Unit)? = null, +) { + enum class ChangeType { + UP, DOWN + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt new file mode 100644 index 0000000000..9156f9bf1b --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/InsightsUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.markets.details.impl.ui.state + +import kotlinx.collections.immutable.ImmutableList + +internal data class InsightsUM( + val h24Info: ImmutableList, + val weekInfo: ImmutableList, + val monthInfo: ImmutableList, + val onInfoClick: () -> Unit, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt new file mode 100644 index 0000000000..d1a6d13860 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/LinksUM.kt @@ -0,0 +1,19 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +internal data class LinksUM( + val officialLinks: ImmutableList, + val social: ImmutableList, + val repository: ImmutableList, + val blockchainSite: ImmutableList, + val onLinkClick: (Link) -> Unit, +) { + data class Link( + @DrawableRes val iconRes: Int, + val title: TextReference, + val url: String, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt new file mode 100644 index 0000000000..473bc3a518 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MarketsTokenDetailsUM.kt @@ -0,0 +1,71 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.markets.PriceChangeInterval +import java.math.BigDecimal + +internal data class MarketsTokenDetailsUM( + val tokenName: String, + val priceText: String, + val iconUrl: String, + val dateTimeText: TextReference, + val priceChangePercentText: String, + val priceChangeType: PriceChangeType, + val selectedInterval: PriceChangeInterval, + val markerSet: Boolean, + val chartState: ChartState, + val onSelectedIntervalChange: (PriceChangeInterval) -> Unit, + val infoBottomSheet: TangemBottomSheetConfig, + val triggerPriceChange: StateEvent, + val body: Body, +) { + + data class ChartState( + val status: Status, + val dataProducer: MarketChartDataProducer, + val chartLook: MarketChartLook, + val onLoadRetryClick: () -> Unit, + val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit, + ) { + enum class Status { + LOADING, ERROR, DATA + } + } + + data class InformationBlocks( + val insights: InsightsUM?, + val securityScore: SecurityScoreUM?, + val metrics: MetricsUM?, + val pricePerformance: PricePerformanceUM?, + val links: LinksUM?, + ) + + @Immutable + sealed interface Body { + + data class Error( + val onLoadRetryClick: () -> Unit, + ) : Body + + data object Loading : Body + + data class Content( + val description: Description?, + val infoBlocks: InformationBlocks, + ) : Body + + data object Nothing : Body + } + + data class Description( + val shortDescription: TextReference, + val fullDescription: TextReference?, + val onReadMoreClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt new file mode 100644 index 0000000000..b1982093d4 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/MetricsUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.markets.details.impl.ui.state + +import kotlinx.collections.immutable.PersistentList + +internal data class MetricsUM( + val metrics: PersistentList, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt new file mode 100644 index 0000000000..e56e10312e --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/PricePerformanceUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.annotation.FloatRange + +internal data class PricePerformanceUM( + val h24: Value, + val month: Value, + val all: Value, +) { + data class Value( + val low: String, + val high: String, + @FloatRange(from = 0.0, to = 1.0) val indicatorFraction: Float, + ) +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/SecurityScoreUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/SecurityScoreUM.kt new file mode 100644 index 0000000000..35f35d4b9c --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/state/SecurityScoreUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.markets.details.impl.ui.state + +import androidx.annotation.FloatRange + +internal data class SecurityScoreUM( + @FloatRange(from = 0.0, to = 5.0) val score: Float, + val description: String, + val onInfoClick: () -> Unit, +) \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt index f88b0b5ad0..c48bcbd802 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ComponentModule.kt @@ -1,7 +1,7 @@ package com.tangem.features.markets.di -import com.tangem.features.markets.component.MarketsListComponent -import com.tangem.features.markets.component.impl.DefaultMarketsListComponent +import com.tangem.features.markets.component.MarketsEntryComponent +import com.tangem.features.markets.DefaultMarketsEntryComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -14,5 +14,5 @@ internal interface ComponentModule { @Binds @Singleton - fun bindMarketsListComponent(factory: DefaultMarketsListComponent.Factory): MarketsListComponent.Factory + fun bindMarketsListComponent(factory: DefaultMarketsEntryComponent.Factory): MarketsEntryComponent.Factory } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt new file mode 100644 index 0000000000..6843a4d610 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.markets.tokenlist.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.component.BottomSheetState + +@Stable +interface MarketsTokenListComponent { + + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory { + fun create( + context: AppComponentContext, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + ): MarketsTokenListComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt new file mode 100644 index 0000000000..64c1f0f751 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt @@ -0,0 +1,70 @@ +package com.tangem.features.markets.tokenlist.impl + +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarket +import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel +import com.tangem.features.markets.tokenlist.impl.ui.MarketsList +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class DefaultMarketsTokenListComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val onTokenSelected: (TokenMarket, AppCurrency) -> Unit, +) : AppComponentContext by appComponentContext, MarketsTokenListComponent { + + private val model: MarketsListModel = getOrCreateModel() + + init { + model.tokenSelected + .onEach { onTokenSelected(it.first, it.second) } + .launchIn(componentScope) + } + + @Composable + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { + LifecycleStartEffect(Unit) { + model.isVisibleOnScreen.value = true + onStopOrDispose { + model.isVisibleOnScreen.value = false + } + } + + val state by model.state.collectAsStateWithLifecycle() + val bsState by bottomSheetState + + LaunchedEffect(bsState) { + model.containerBottomSheetState.value = bsState + } + + MarketsList( + modifier = modifier, + state = state, + onHeaderSizeChange = onHeaderSizeChange, + bottomSheetState = bsState, + ) + } + + @AssistedFactory + interface Factory : MarketsTokenListComponent.Factory { + override fun create( + context: AppComponentContext, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + ): DefaultMarketsTokenListComponent + } +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..bb44e19164 --- /dev/null +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ComponentModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.markets.tokenlist.impl.di + +import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent +import com.tangem.features.markets.tokenlist.impl.DefaultMarketsTokenListComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindMarketsTokenListComponent( + factory: DefaultMarketsTokenListComponent.Factory, + ): MarketsTokenListComponent.Factory +} \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ModelModule.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ModelModule.kt similarity index 78% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ModelModule.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ModelModule.kt index 9ee24735da..17aa3f4cd5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/di/ModelModule.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/di/ModelModule.kt @@ -1,8 +1,8 @@ -package com.tangem.features.markets.di +package com.tangem.features.markets.tokenlist.impl.di import com.tangem.core.decompose.di.DecomposeComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.markets.model.MarketsListModel +import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt similarity index 85% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt index 97fc37c161..b2d0ca1844 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.model +package com.tangem.features.markets.tokenlist.impl.model import androidx.compose.runtime.Stable import arrow.core.getOrElse @@ -7,11 +7,13 @@ import com.tangem.core.decompose.model.Model import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase +import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.model.statemanager.MarketsListUMStateManager -import com.tangem.features.markets.model.statemanager.MarketsListBatchFlowManager -import com.tangem.features.markets.ui.entity.ListUM -import com.tangem.features.markets.ui.entity.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListUMStateManager +import com.tangem.features.markets.tokenlist.impl.model.statemanager.MarketsListBatchFlowManager +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -32,6 +34,8 @@ internal class MarketsListModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, ) : Model() { + private var updateQuotesJob = JobHolder() + private val currentAppCurrency = getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } @@ -44,11 +48,12 @@ internal class MarketsListModel @Inject constructor( private val visibleItemIds = MutableStateFlow>(emptyList()) private val marketsListUMStateManager = MarketsListUMStateManager( + currentVisibleIds = Provider { visibleItemIds.value }, onLoadMoreUiItems = { activeListManager.loadMore() }, visibleItemsChanged = { visibleItemIds.value = it }, onRetryButtonClicked = { activeListManager.reload() }, + onTokenClick = { onTokenUIClicked(it) }, ) - private val mainMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, @@ -59,6 +64,7 @@ internal class MarketsListModel @Inject constructor( modelScope = modelScope, dispatchers = dispatchers, ) + private val searchMarketsListManager = MarketsListBatchFlowManager( getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, @@ -72,7 +78,12 @@ internal class MarketsListModel @Inject constructor( private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager + private val _tokenSelected = MutableSharedFlow>() + + val tokenSelected = _tokenSelected.asSharedFlow() + val containerBottomSheetState = MutableStateFlow(BottomSheetState.COLLAPSED) + val isVisibleOnScreen = MutableStateFlow(false) val state = marketsListUMStateManager.state.asStateFlow() @@ -169,6 +180,7 @@ internal class MarketsListModel @Inject constructor( } .distinctUntilChanged() .collectLatest { visibleBatchKeys -> + // TODO load batch on scroll heat area activeListManager.loadCharts(visibleBatchKeys, marketsListUMStateManager.selectedInterval) } } @@ -189,9 +201,12 @@ internal class MarketsListModel @Inject constructor( modelScope.launch { marketsListUMStateManager.searchQueryFlow - .filter { it.isNotEmpty() } .debounce(timeoutMillis = SEARCH_QUERY_DEBOUNCE_MILLIS) - .filter { activeListManager == searchMarketsListManager } + .distinctUntilChanged() + .onEach { + if (it.isEmpty()) searchMarketsListManager.clearStateAndStopAllActions() + } + .filter { it.isNotEmpty() && activeListManager == searchMarketsListManager } .collectLatest { searchMarketsListManager.reload(searchText = it) } @@ -210,14 +225,24 @@ internal class MarketsListModel @Inject constructor( mainMarketsListManager.reload() } - private var updateQuotesJob = JobHolder() + private fun onTokenUIClicked(token: MarketsListItemUM) { + modelScope.launch { + activeListManager.getTokenById(token.id)?.let { found -> + _tokenSelected.emit(found to currentAppCurrency.value) + } + } + } + private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { launch { while (true) { delay(timeMillis) // Update quotes only when the container bottom sheet is in the expanded state containerBottomSheetState.first { it == BottomSheetState.EXPANDED } - activeListManager.updateQuotes() // TODO update a batch that is currently on screen + // and is visible on the screen + isVisibleOnScreen.first { it } + + activeListManager.updateQuotes() } }.saveIn(updateQuotesJob) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt similarity index 69% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt index c2badcd569..c43e29f406 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt @@ -1,15 +1,16 @@ -package com.tangem.features.markets.model.converters +package com.tangem.features.markets.tokenlist.impl.model.converters -import com.tangem.common.ui.charts.state.DefaultPointValuesConverter +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal import java.math.RoundingMode @@ -18,6 +19,8 @@ internal class MarketsTokenItemConverter( private val appCurrency: AppCurrency, ) : Converter { + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = false) + override fun convert(value: TokenMarket): MarketsListItemUM { return MarketsListItemUM( id = value.id, @@ -30,7 +33,7 @@ internal class MarketsTokenItemConverter( trendPercentText = value.getTrendPercent(), trendType = value.getTrendType(), chardData = value.getChartData(), - showUnder100kMarketCap = value.isUnder100kMarketCap(), + isUnder100kMarketCap = value.isUnder100kMarketCap(), ) } @@ -45,17 +48,17 @@ internal class MarketsTokenItemConverter( ratingPosition = new.marketRating?.toString(), marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() }, iconUrl = new.imageUrlLarge, - price = ifChanged(prev = prev.tokenQuotes, new = new.tokenQuotes, prevR = prevUI.price) { + price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) { new.getCurrentPrice( prev = prev, ) }, trendPercentText = ifChanged( - prev.tokenQuotes, - new.tokenQuotes, + prev.tokenQuotesShort, + new.tokenQuotesShort, prevUI.trendPercentText, ) { new.getTrendPercent() }, - trendType = ifChanged(prev.tokenQuotes, new.tokenQuotes, prevUI.trendType) { new.getTrendType() }, + trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() }, chardData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chardData) { new.getChartData() }, ) } @@ -67,24 +70,25 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getMarketCap(): String? { val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null - return BigDecimalFormatter.formatCompactAmount( - value, + return BigDecimalFormatter.formatCompactFiatAmount( + amount = value, fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, + threeDigitsMethod = true, ) } private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price { - val prevPrice = prev?.tokenQuotes?.currentPrice + val prevPrice = prev?.tokenQuotesShort?.currentPrice - val priceText = BigDecimalFormatter.formatFiatAmountUncapped( - fiatAmount = tokenQuotes.currentPrice, + val priceText = BigDecimalFormatter.formatFiatPriceUncapped( + fiatAmount = tokenQuotesShort.currentPrice, fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol, ) val changeType = if (prevPrice != null) { - if (tokenQuotes.currentPrice > prevPrice) { + if (tokenQuotesShort.currentPrice > prevPrice) { PriceChangeType.UP } else { PriceChangeType.DOWN @@ -107,10 +111,10 @@ internal class MarketsTokenItemConverter( } return chart?.let { ct -> - DefaultPointValuesConverter.convert( + priceAndTimePointValuesConverter.convert( MarketChartData.Data( - y = ct.priceY, - x = ct.timeStamp.map { it.toBigDecimal() }, + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), ), ) } @@ -118,9 +122,9 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getTrendType(): PriceChangeType { val percent = when (currentTrendInterval) { - TrendInterval.H24 -> tokenQuotes.h24Percent() - TrendInterval.D7 -> tokenQuotes.weekPercent() - TrendInterval.M1 -> tokenQuotes.monthPercent() + TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent + TrendInterval.D7 -> tokenQuotesShort.weekChangePercent + TrendInterval.M1 -> tokenQuotesShort.monthChangePercent }.setScale(2, RoundingMode.UP) return when (percent.compareTo(BigDecimal.ZERO)) { @@ -132,9 +136,9 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getTrendPercent(): String { val percent = when (currentTrendInterval) { - TrendInterval.H24 -> tokenQuotes.h24Percent() - TrendInterval.D7 -> tokenQuotes.weekPercent() - TrendInterval.M1 -> tokenQuotes.monthPercent() + TrendInterval.H24 -> tokenQuotesShort.h24ChangePercent + TrendInterval.D7 -> tokenQuotesShort.weekChangePercent + TrendInterval.M1 -> tokenQuotesShort.monthChangePercent } return BigDecimalFormatter.formatPercent( @@ -144,7 +148,7 @@ internal class MarketsTokenItemConverter( } private fun TokenMarket.isUnder100kMarketCap(): Boolean { - return tokenQuotes.currentPrice.compareTo(decimal100k) == -1 + return marketCap?.let { it < decimal100k } ?: true } private companion object { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt similarity index 63% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt index ae897a5b9a..bc5dcaed36 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListBatchFlowManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt @@ -1,23 +1,27 @@ -package com.tangem.features.markets.model.statemanager +package com.tangem.features.markets.tokenlist.impl.model.statemanager import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* -import com.tangem.features.markets.model.converters.MarketsTokenItemConverter -import com.tangem.features.markets.model.utils.logAction -import com.tangem.features.markets.model.utils.logStatus -import com.tangem.features.markets.model.utils.logUpdateResults -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.entity.MarketsListUM.TrendInterval -import com.tangem.features.markets.ui.entity.SortByTypeUM -import com.tangem.pagination.* +import com.tangem.features.markets.tokenlist.impl.model.converters.MarketsTokenItemConverter +import com.tangem.features.markets.tokenlist.impl.model.utils.logAction +import com.tangem.features.markets.tokenlist.impl.model.utils.logStatus +import com.tangem.features.markets.tokenlist.impl.model.utils.logUpdateResults +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM.TrendInterval +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM +import com.tangem.pagination.Batch +import com.tangem.pagination.BatchAction +import com.tangem.pagination.BatchFetchResult +import com.tangem.pagination.PaginationStatus import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch private const val LOG_EVENTS = true @@ -33,6 +37,7 @@ internal class MarketsListBatchFlowManager( private val dispatchers: CoroutineDispatcherProvider, ) { private val actionsFlow = MutableSharedFlow>() + private val updateStateJob = JobHolder() private val batchFlow = getMarketsTokenListFlowUseCase( batchingContext = TokenListBatchingContext( @@ -42,6 +47,9 @@ internal class MarketsListBatchFlowManager( batchFlowType = batchFlowType, ) + private val resultBatches = MutableStateFlow(ResultBatches()) + private val uiBatches = resultBatches.map { it.uiBatches } + val uiItems: StateFlow> get() = uiBatches .map { batches -> @@ -97,16 +105,20 @@ internal class MarketsListBatchFlowManager( initialValue = false, ) - private val uiBatches = MutableStateFlow>>>(emptyList()) - init { batchFlow.state .map { it.data } .distinctUntilChanged { a, b -> - a.size == b.size && a.map { it.data }.flatten() == b.map { it.data }.flatten() + a.size == b.size && + a.map { it.key } == b.map { it.key } && + a.map { it.data }.flatten() == b.map { it.data }.flatten() } - .onEachWithPrevious { prev, list -> - updateState(prev, list) + .onEach { + coroutineScope { + launch { + updateState(it) + }.saveIn(updateStateJob) + } } .flowOn(dispatchers.default) .launchIn(modelScope) @@ -127,58 +139,75 @@ internal class MarketsListBatchFlowManager( } } - private fun updateState( - previousList: List>>?, - list: List>>, - forceUpdate: Boolean = false, - ) = uiBatches.update { items -> - val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency()) + private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = + withContext(dispatchers.default) { + resultBatches.update { resultBatches -> + val items = resultBatches.uiBatches + val previousList = resultBatches.processedItems - if (previousList == null || list.size < previousList.size || forceUpdate) { - list.map { - Batch( - key = it.key, - data = converter.convertList(it.data), + val converter = MarketsTokenItemConverter(currentTrendInterval(), appCurrency = currentAppCurrency()) + + if (newList.isEmpty()) { + return@update ResultBatches(processedItems = emptyList()) + } + + val isInitialLoading = + forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key + + val outItems = if (isInitialLoading) { + newList.map { + Batch( + key = it.key, + data = converter.convertList(it.data), + ) + } + } else { + previousList!! + if (previousList.size != newList.size) { + val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) + val newBatches = newList.filter { keysToAdd.contains(it.key) } + + items + newBatches.map { + Batch( + key = it.key, + data = converter.convertList(it.data), + ) + } + } else { + items.mapIndexed { batchIndex, batch -> + val prevBatch = previousList[batchIndex] + val newBatch = newList[batchIndex] + if (previousList == newBatch) return@mapIndexed batch + + Batch( + key = batch.key, + data = batch.data.mapIndexed { index, marketsListItemUM -> + val prevItem = prevBatch.data[index] + val newItem = newBatch.data[index] + + converter.update( + prevItem, + marketsListItemUM, + newItem, + ) + }, + ) + } + } + } + + currentCoroutineContext().ensureActive() + + ResultBatches( + uiBatches = outItems, + processedItems = newList, ) } - } else { - if (previousList.size != list.size) { - val keysToAdd = list.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = list.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { - Batch( - key = it.key, - data = converter.convertList(it.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = list[batchIndex] - if (previousList == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data[index] - val newItem = newBatch.data[index] - - converter.update( - prevItem, - marketsListItemUM, - newItem, - ) - }, - ) - } - } } - } fun reload(searchText: String? = null) { modelScope.launch { - uiBatches.value = emptyList() + resultBatches.value = ResultBatches() actionsFlow.emit( BatchAction.Reload( requestParams = TokenMarketListConfig( @@ -188,7 +217,6 @@ internal class MarketsListBatchFlowManager( } else { searchText ?: currentSearchText() }, - showUnder100kMarketCapTokens = false, // TODO priceChangeInterval = currentTrendInterval().toBatchRequestInterval(), order = currentSortByType().toRequestOrder(), ), @@ -206,12 +234,14 @@ internal class MarketsListBatchFlowManager( fun updateUIWithSameState() { modelScope.launch(dispatchers.default) { val current = batchFlow.state.value.data - updateState(current, current, forceUpdate = true) - } + updateState(current, forceUpdate = true) + }.saveIn(updateStateJob) } fun loadCharts(batchKeys: Set, interval: TrendInterval) { - modelScope.launch(dispatchers.default) { + if (batchKeys.isEmpty()) return + + modelScope.launch { val currentData = batchFlow.state.value.data val alreadyLoadedChartsBatchKeys = currentData .filter { @@ -233,7 +263,7 @@ internal class MarketsListBatchFlowManager( BatchAction.UpdateBatches( keys = batchesKeysToLoad, updateRequest = TokenMarketUpdateRequest.UpdateChart( - interval = interval.toRequestInterval(), + interval = interval.toBatchRequestInterval(), currency = currentAppCurrency().code, ), async = true, @@ -266,7 +296,7 @@ internal class MarketsListBatchFlowManager( } fun clearStateAndStopAllActions() { - uiBatches.value = emptyList() + resultBatches.value = ResultBatches() modelScope.launch { actionsFlow.emit(BatchAction.Reset) } @@ -281,6 +311,10 @@ internal class MarketsListBatchFlowManager( .toSet() } + fun getTokenById(id: String): TokenMarket? { + return batchFlow.state.value.data.map { it.data }.flatten().find { it.id == id } + } + private fun SortByTypeUM.toRequestOrder(): TokenMarketListConfig.Order { return when (this) { SortByTypeUM.Rating -> TokenMarketListConfig.Order.ByRating @@ -299,20 +333,8 @@ internal class MarketsListBatchFlowManager( } } - private fun TrendInterval.toRequestInterval(): PriceChangeInterval { - return when (this) { - TrendInterval.H24 -> PriceChangeInterval.H24 - TrendInterval.D7 -> PriceChangeInterval.WEEK - TrendInterval.M1 -> PriceChangeInterval.MONTH - } - } - - private fun Flow.onEachWithPrevious(operation: suspend (prev: T?, value: T) -> Unit): Flow = flow { - var prev: T? = null - collect { value -> - operation(prev, value) - prev = value - emit(value) - } - } + private data class ResultBatches( + val uiBatches: List>> = emptyList(), + val processedItems: List>>? = null, + ) } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt similarity index 57% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt index d845850f44..50170ce417 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/statemanager/MarketsListUMStateManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListUMStateManager.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.model.statemanager +package com.tangem.features.markets.tokenlist.impl.model.statemanager import androidx.compose.runtime.Stable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -7,19 +7,24 @@ import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM -import com.tangem.features.markets.ui.entity.ListUM -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.entity.MarketsListUM -import com.tangem.features.markets.ui.entity.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM +import com.tangem.utils.Provider import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* @Stable internal class MarketsListUMStateManager( + private val currentVisibleIds: Provider>, private val onLoadMoreUiItems: () -> Unit, private val visibleItemsChanged: (itemsKeys: List) -> Unit, private val onRetryButtonClicked: () -> Unit, + private val onTokenClick: (MarketsListItemUM) -> Unit, ) { private var sortByBottomSheetIsShown @@ -98,21 +103,92 @@ internal class MarketsListUMStateManager( it.copy(list = ListUM.Loading) } else -> { - it.copy( - list = ListUM.Content( - items = uiItems, - loadMore = onLoadMoreUiItems, - visibleIdsChanged = visibleItemsChanged, - showUnder100kTokens = true, - onShowTokensUnder100kClicked = { }, - triggerScrollReset = consumedEvent(), - ), - ) + it.updateItems(newItems = uiItems) } } } } + private fun MarketsListUM.updateItems(newItems: ImmutableList): MarketsListUM { + val currentState = this + val isNextPageInSearch = isInSearchState && (this.list as? ListUM.Content)?.showUnder100kTokens == true + var searchUiItemsCached: ImmutableList = persistentListOf() + + val items = when { + isInSearchState && isNextPageInSearch.not() -> { + searchUiItemsCached = newItems + val filtered = newItems.filter { item -> item.isUnder100kMarketCap.not() }.toImmutableList() + + if (filtered.size == newItems.size) { + return currentState.copy(list = generalContentState(newItems)) + } else { + filtered + } + } + else -> { + searchUiItemsCached = persistentListOf() + newItems + } + } + + val itemsWithFilteredPriceChange = items.filterPriceChangeByVisibility() + + return currentState.copy( + list = ListUM.Content( + items = itemsWithFilteredPriceChange, + loadMore = onLoadMoreUiItems, + visibleIdsChanged = visibleItemsChanged, + showUnder100kTokens = isInSearchState.not() || isNextPageInSearch, + onShowTokensUnder100kClicked = { + if (searchUiItemsCached.isNotEmpty()) { + state.update { s -> + if (s.list is ListUM.Content) { + s.copy( + list = s.list.copy( + items = searchUiItemsCached, + showUnder100kTokens = true, + ), + ) + } else { + s + } + } + } + }, + triggerScrollReset = consumedEvent(), + onItemClick = onTokenClick, + ), + ) + } + + // Show price change animation for visible items only + private fun ImmutableList.filterPriceChangeByVisibility(): ImmutableList { + val visibleItemIds = currentVisibleIds() + return map { + it.copy( + price = it.price.copy( + changeType = if (visibleItemIds.contains(it.id)) { + it.price.changeType + } else { + null + }, + ), + ) + }.toImmutableList() + } + + private fun generalContentState(newItems: ImmutableList): ListUM.Content { + return ListUM.Content( + items = newItems, + loadMore = onLoadMoreUiItems, + visibleIdsChanged = visibleItemsChanged, + showUnder100kTokens = true, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), + onItemClick = onTokenClick, + ) + } + private fun state(): MarketsListUM = MarketsListUM( list = ListUM.Loading, searchBar = SearchBarUM( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/utils/LoggingUtils.kt similarity index 96% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/utils/LoggingUtils.kt index e9cc0e8b31..8b612f6db5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/model/utils/LoggingUtils.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/utils/LoggingUtils.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.model.utils +package com.tangem.features.markets.tokenlist.impl.model.utils import com.tangem.domain.markets.TokenMarket import com.tangem.domain.markets.TokenMarketListConfig diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt similarity index 69% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt index 3ff1f1d4e7..6dcb360962 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt @@ -1,16 +1,15 @@ -package com.tangem.features.markets.ui +package com.tangem.features.markets.tokenlist.impl.ui +import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -32,17 +31,18 @@ import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.markets.component.BottomSheetState import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.components.MarketsListLazyColumn -import com.tangem.features.markets.ui.components.MarketsListSortByBottomSheet -import com.tangem.features.markets.ui.entity.ListUM -import com.tangem.features.markets.ui.entity.MarketsListUM -import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM -import com.tangem.features.markets.ui.entity.SortByTypeUM -import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider +import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListLazyColumn +import com.tangem.features.markets.tokenlist.impl.ui.components.MarketsListSortByBottomSheet +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -68,23 +68,28 @@ internal fun MarketsList( @Composable private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) { val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier .fillMaxSize() .imePadding() - .background(color = TangemTheme.colors.background.primary), + .drawBehind { drawRect(background) }, ) { SearchBar( modifier = Modifier - .background(color = TangemTheme.colors.background.primary) + .drawBehind { drawRect(background) } .padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing16, bottom = TangemTheme.dimens.spacing4, ) .onGloballyPositioned { - with(density) { onHeaderSizeChange(it.size.height.toDp()) } + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } + } }, state = state.searchBar, ) @@ -225,44 +230,52 @@ private fun KeyboardEvents(isSortByBottomSheetShown: Boolean, bottomSheetState: //region: Preview @Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun Preview() { - TangemThemePreview { - MarketsList( - state = MarketsListUM( - list = ListUM.Content( - items = MarketChartListItemPreviewDataProvider().values - .flatMap { item -> List(size = 10) { item } } - .mapIndexed { index, item -> - item.copy(id = index.toString()) - } - .toImmutableList(), - showUnder100kTokens = false, - loadMore = {}, - visibleIdsChanged = {}, - onShowTokensUnder100kClicked = {}, - triggerScrollReset = consumedEvent(), + TangemThemePreview(alwaysShowBottomSheets = false) { + val primaryBackground = TangemTheme.colors.background.primary + + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(primaryBackground) }, + ) { + MarketsList( + state = MarketsListUM( + list = ListUM.Content( + items = MarketChartListItemPreviewDataProvider().values + .flatMap { item -> List(size = 10) { item } } + .mapIndexed { index, item -> + item.copy(id = index.toString()) + } + .toImmutableList(), + showUnder100kTokens = false, + loadMore = {}, + visibleIdsChanged = {}, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), + onItemClick = {}, + ), + searchBar = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = { }, + ), + selectedSortBy = SortByTypeUM.Rating, + selectedInterval = MarketsListUM.TrendInterval.H24, + onIntervalClick = {}, + onSortByButtonClick = {}, + sortByBottomSheet = TangemBottomSheetConfig( + isShow = false, + onDismissRequest = {}, + content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, + ), ), - searchBar = SearchBarUM( - placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = { }, - ), - selectedSortBy = SortByTypeUM.Rating, - selectedInterval = MarketsListUM.TrendInterval.H24, - onIntervalClick = {}, - onSortByButtonClick = {}, - sortByBottomSheet = TangemBottomSheetConfig( - isShow = false, - onDismissRequest = {}, - content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, - ), - ), - onHeaderSizeChange = {}, - bottomSheetState = BottomSheetState.EXPANDED, - ) + onHeaderSizeChange = {}, + bottomSheetState = BottomSheetState.EXPANDED, + ) + } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt similarity index 96% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt index 847f063309..7658893fb7 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItem.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItem.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.animation.Animatable @@ -39,14 +39,15 @@ import com.tangem.core.ui.components.* import com.tangem.core.ui.components.currency.icon.CoinIcon import com.tangem.core.ui.components.marketprice.PriceChangeInPercent import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.windowsize.WindowSizeType import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.entity.MarketsListItemUM -import com.tangem.features.markets.ui.preview.MarketChartListItemPreviewDataProvider +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.preview.MarketChartListItemPreviewDataProvider import com.tangem.utils.StringsSigns.MINUS import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -97,14 +98,14 @@ fun MarketsListItem( .collect { val border = -actionWidthPx * SWIPE_THRESHOLD_PERCENT if (it < border && actionPerformed.not()) { - hapticManager.vibrateLong() + hapticManager.perform(TangemHapticEffect.View.GestureThresholdActivate) actionPerformed = true releasePerformed = false } if (it > border) { if (releasePerformed.not()) { - hapticManager.vibrateShort() + hapticManager.perform(TangemHapticEffect.View.GestureThresholdDeactivate) releasePerformed = true } actionPerformed = false diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItemPlaceholder.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItemPlaceholder.kt similarity index 79% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItemPlaceholder.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItemPlaceholder.kt index 251844c2d6..692b663cc8 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListItemPlaceholder.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListItemPlaceholder.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.foundation.background @@ -34,11 +34,11 @@ fun MarketsListItemPlaceholder() { SpacerW12() Column(modifier = Modifier.weight(1f)) { - Row( + Box( modifier = Modifier .fillMaxWidth() .padding(vertical = TangemTheme.dimens.spacing4), - horizontalArrangement = Arrangement.SpaceBetween, + contentAlignment = Alignment.CenterStart, ) { RectangleShimmer( modifier = Modifier @@ -46,23 +46,15 @@ fun MarketsListItemPlaceholder() { .height(sp12), radius = TangemTheme.dimens.radius3, ) - SpacerW8() - RectangleShimmer( - modifier = Modifier - .width(TangemTheme.dimens.size70) - .height(sp12), - radius = TangemTheme.dimens.radius3, - ) } SpacerH(height = TangemTheme.dimens.spacing2) - Row( + Box( modifier = Modifier .fillMaxWidth() .padding(vertical = TangemTheme.dimens.spacing2), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, + contentAlignment = Alignment.CenterStart, ) { RectangleShimmer( modifier = Modifier @@ -70,12 +62,6 @@ fun MarketsListItemPlaceholder() { .height(sp12), radius = TangemTheme.dimens.radius3, ) - RectangleShimmer( - modifier = Modifier - .width(TangemTheme.dimens.size52) - .height(sp12), - radius = TangemTheme.dimens.radius3, - ) } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt similarity index 86% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index dd7753fb72..0da161d62a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -9,10 +9,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import com.tangem.core.ui.components.buttons.SecondarySmallButton @@ -20,11 +16,13 @@ import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.disableNestedScroll import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.entity.ListUM +import com.tangem.features.markets.tokenlist.impl.ui.state.ListUM import kotlinx.coroutines.launch private const val LOAD_NEXT_PAGE_ON_END_INDEX = 50 +private const val TOKEN_LAZY_LIST_ID_SEPARATOR = "***" @Composable @Suppress("LongMethod") @@ -53,7 +51,7 @@ internal fun MarketsListLazyColumn( if (state is ListUM.Loading) { LazyColumn( - modifier = Modifier.nestedScroll(DisableParentConnection), + modifier = Modifier.disableNestedScroll(), state = rememberLazyListState(), contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = false, @@ -64,7 +62,7 @@ internal fun MarketsListLazyColumn( } } else { LazyColumn( - modifier = modifier.nestedScroll(DisableParentConnection), + modifier = modifier.disableNestedScroll(), state = lazyListState, contentPadding = PaddingValues(bottom = bottomBarHeight), userScrollEnabled = true, @@ -89,9 +87,12 @@ internal fun MarketsListLazyColumn( is ListUM.Content -> { items( items = state.items, - key = { it.id }, + key = { it.id + TOKEN_LAZY_LIST_ID_SEPARATOR + it.marketCap.toString() }, ) { item -> - MarketsListItem(model = item) + MarketsListItem( + model = item, + onClick = { state.onItemClick(item) }, + ) } if (isInSearchMode && state.showUnder100kTokens.not()) { @@ -114,8 +115,11 @@ internal fun MarketsListLazyColumn( buffer = LOAD_NEXT_PAGE_ON_END_INDEX, onLoadMore = remember(state) { { - if (state is ListUM.Content) { + if (state is ListUM.Content && state.showUnder100kTokens) { state.loadMore() + true + } else { + false } } }, @@ -181,7 +185,9 @@ private fun SearchNothingFoundText(modifier: Modifier = Modifier) { private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { val visibleItems by remember { derivedStateOf { - listState.layoutInfo.visibleItemsInfo.mapNotNull { it.key as? String } + listState.layoutInfo.visibleItemsInfo.mapNotNull { + (it.key as? String)?.split(TOKEN_LAZY_LIST_ID_SEPARATOR)?.first() + } } } @@ -193,7 +199,7 @@ private fun VisibleItemsTracker(listState: LazyListState, state: ListUM) { } @Composable -fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer: Int = 2) { +fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Boolean, buffer: Int = 2) { val loadMore by remember { derivedStateOf { val layoutInfo = listState.layoutInfo @@ -209,14 +215,7 @@ fun InfiniteListHandler(listState: LazyListState, onLoadMore: () -> Unit, buffer LaunchedEffect(loadMore) { if (loadMore && !emitted) { - emitted = true - onLoadMore() + emitted = onLoadMore() } } -} - -private object DisableParentConnection : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - return available.copy(x = 0f) - } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt similarity index 93% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt index 4ee563d08c..b5c0966102 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/MarketsListSortByBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.foundation.background @@ -19,8 +19,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.markets.impl.R -import com.tangem.features.markets.ui.entity.SortByBottomSheetContentUM -import com.tangem.features.markets.ui.entity.SortByTypeUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByBottomSheetContentUM +import com.tangem.features.markets.tokenlist.impl.ui.state.SortByTypeUM @Composable fun MarketsListSortByBottomSheet(config: TangemBottomSheetConfig) { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt similarity index 96% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt index cf58422fb5..4cec85a065 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/components/UnableToLoadData.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.components +package com.tangem.features.markets.tokenlist.impl.ui.components import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/preview/MarketChartListItemPreviewDataProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt similarity index 82% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/preview/MarketChartListItemPreviewDataProvider.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt index 782d4bfbc8..41d1596f68 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/preview/MarketChartListItemPreviewDataProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt @@ -1,10 +1,11 @@ @file:Suppress("MagicNumber") -package com.tangem.features.markets.ui.preview +package com.tangem.features.markets.tokenlist.impl.ui.preview import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.features.markets.ui.entity.MarketsListItemUM +import com.tangem.features.markets.tokenlist.impl.ui.state.MarketsListItemUM +import kotlinx.collections.immutable.persistentListOf internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider( collection = listOf( @@ -19,7 +20,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -45,7 +46,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.DOWN, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -59,7 +60,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -73,7 +74,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -87,7 +88,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), ), diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt similarity index 77% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt index a561cf8517..9f3821c9c5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListItemUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListItemUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.entity +package com.tangem.features.markets.tokenlist.impl.ui.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.charts.state.MarketChartLook @@ -17,13 +17,12 @@ data class MarketsListItemUM( val trendPercentText: String, val trendType: PriceChangeType, val chardData: MarketChartRawData?, - val showUnder100kMarketCap: Boolean = false, + val isUnder100kMarketCap: Boolean = false, ) { val chartType: MarketChartLook.Type = when (trendType) { - PriceChangeType.UP, - PriceChangeType.NEUTRAL, - -> MarketChartLook.Type.Growing + PriceChangeType.UP -> MarketChartLook.Type.Growing PriceChangeType.DOWN -> MarketChartLook.Type.Falling + PriceChangeType.NEUTRAL -> MarketChartLook.Type.Neutral } @Immutable diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt similarity index 94% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt index 9167060698..7906cc1fb9 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/MarketsListUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/MarketsListUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.entity +package com.tangem.features.markets.tokenlist.impl.ui.state import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -46,6 +46,7 @@ sealed class ListUM { val visibleIdsChanged: (List) -> Unit, val onShowTokensUnder100kClicked: () -> Unit, val triggerScrollReset: StateEvent, + val onItemClick: (MarketsListItemUM) -> Unit, ) : ListUM() data object Loading : ListUM() diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/SortByBottomSheetContentUM.kt similarity index 80% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt rename to features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/SortByBottomSheetContentUM.kt index af420055f5..f3b4e9682b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/ui/entity/SortByBottomSheetContentUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/state/SortByBottomSheetContentUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.ui.entity +package com.tangem.features.markets.tokenlist.impl.ui.state import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent diff --git a/features/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/SendNavigationButtons.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt index 3e676181c9..18d8413082 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.ui.SendDoneButtons -import com.tangem.common.ui.amountScreen.utils.getCryptoReference import com.tangem.common.ui.amountScreen.utils.getFiatString import com.tangem.core.ui.R import com.tangem.core.ui.components.Keyboard @@ -34,6 +33,7 @@ import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType @@ -186,16 +186,6 @@ private fun SendingText( fiatCurrencySymbol = feeState.appCurrency.symbol, fiatCurrencyCode = feeState.appCurrency.code, ) - val feeValue = if (feeState.isFeeConvertibleToFiat) { - getFiatString( - value = feeState.fee?.amount?.value, - rate = feeState.rate, - appCurrency = feeState.appCurrency, - ) - } else { - getCryptoReference(feeState.fee?.amount, feeState.isFeeApproximate)?.resolveReference().orEmpty() - } - val textResource = remember(uiState) { resourceReference( id = if (feeState.isFeeConvertibleToFiat) { @@ -203,7 +193,7 @@ private fun SendingText( } else { R.string.send_summary_transaction_description_no_fiat_fee }, - formatArgs = wrappedList(sendingValue, feeValue), + formatArgs = wrappedList(sendingValue, feeState.getFiatValue()), ) } Text( @@ -219,6 +209,22 @@ private fun SendingText( } } +private fun SendStates.FeeState.getFiatValue() = if (isFeeConvertibleToFiat) { + getFiatString( + value = fee?.amount?.value, + rate = rate, + appCurrency = appCurrency, + ) +} else { + val amount = fee?.amount + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = amount?.value, + cryptoCurrency = amount?.currencySymbol.orEmpty(), + decimals = amount?.decimals ?: 0, + canBeLower = isFeeApproximate, + ) +} + private fun getButtonData( uiState: SendUiState, currentState: SendUiCurrentScreen, @@ -245,11 +251,11 @@ private fun getButtonData( private fun isButtonEnabled(currentState: SendUiCurrentScreen, uiState: SendUiState): Boolean { return when (currentState.type) { - SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled + SendUiStateType.Amount -> uiState.amountState.isPrimaryButtonEnabled SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled SendUiStateType.Send -> uiState.sendState?.isPrimaryButtonEnabled - SendUiStateType.EditAmount -> uiState.editAmountState?.isPrimaryButtonEnabled + SendUiStateType.EditAmount -> uiState.editAmountState.isPrimaryButtonEnabled SendUiStateType.EditRecipient -> uiState.editRecipientState?.isPrimaryButtonEnabled SendUiStateType.EditFee -> uiState.editFeeState?.isPrimaryButtonEnabled else -> true diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt index f7aacce75a..712f915547 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelectorItem.kt @@ -11,11 +11,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.ui.amountScreen.utils.getCryptoReference import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.rows.SelectorRowItem +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.parseToBigDecimal @@ -52,7 +52,14 @@ internal fun SendSpeedSelectorItem( iconRes = iconRes, onSelect = onSelect, modifier = modifier, - preDot = getCryptoReference(amount, state.isFeeApproximate), + preDot = stringReference( + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = amount?.value, + cryptoCurrency = amount?.currencySymbol.orEmpty(), + decimals = amount?.decimals ?: 0, + canBeLower = state.isFeeApproximate, + ), + ), postDot = if (state.isFeeConvertibleToFiat) { getFiatReference(amount?.value, state.rate, state.appCurrency) } else { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt index cf63542cee..75729e23b1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt @@ -141,7 +141,7 @@ private fun LazyListScope.memoField(memoField: SendTextField.RecipientMemo?, onM onValueChange = memoField.onValueChange, onPasteClick = onMemoChange, modifier = Modifier.padding(top = TangemTheme.dimens.spacing20), - labelStyle = TangemTheme.typography.caption2, + labelStyle = TangemTheme.typography.subtitle2, isError = memoField.isError, error = memoField.error, isReadOnly = !memoField.isEnabled, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt index c43df695b9..b396811b81 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/FeeBlock.kt @@ -14,12 +14,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.tangem.common.ui.amountScreen.utils.getCryptoReference import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.rows.SelectorRowItem -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.features.send.impl.R import com.tangem.features.send.impl.presentation.state.SendStates @@ -61,7 +61,14 @@ internal fun FeeBlock(feeState: SendStates.FeeState, isClickDisabled: Boolean, o SelectorRowItem( titleRes = title, iconRes = icon, - preDot = getCryptoReference(feeAmount, feeState.isFeeApproximate), + preDot = stringReference( + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = feeAmount?.value, + cryptoCurrency = feeAmount?.currencySymbol.orEmpty(), + decimals = feeAmount?.decimals ?: 0, + canBeLower = feeState.isFeeApproximate, + ), + ), postDot = if (feeState.isFeeConvertibleToFiat) { getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency) } else { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt index 4839b83690..313451a067 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt @@ -52,7 +52,7 @@ internal fun RecipientBlock( private fun AddressBlock(address: SendTextField.RecipientAddress) { Text( text = address.label.resolveReference(), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.secondary, ) Row( diff --git a/features/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..88936286d3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingNotification.kt @@ -3,6 +3,7 @@ package com.tangem.features.staking.impl.presentation.state import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.staking.impl.R @@ -23,7 +24,15 @@ internal sealed class StakingNotification(val config: NotificationConfig) { onCloseClick = onCloseClick, ), ) { - // TODO staking + data class StakedPositionNotFoundError(val message: String) : Error( + title = stringReference(message), + subtitle = stringReference(message), + ) + + data class Common(val subtitle: TextReference) : Error( + title = resourceReference(R.string.common_error), + subtitle = subtitle, + ) } sealed class Warning( @@ -41,13 +50,23 @@ internal sealed class StakingNotification(val config: NotificationConfig) { ), ) { data class EarnRewards( + val subtitleResourceId: Int, val currencyName: String, - val days: Int, ) : Warning( title = resourceReference(R.string.staking_notification_earn_rewards_title), subtitle = resourceReference( - R.string.staking_notification_earn_rewards_text, - wrappedList(currencyName, days), + subtitleResourceId, + wrappedList(currencyName), + ), + ) + + data class Unstake( + val cooldownPeriodDays: Int, + ) : Warning( + title = resourceReference(R.string.common_unstake), + subtitle = resourceReference( + R.string.staking_notification_unstake_text, + wrappedList(cooldownPeriodDays, cooldownPeriodDays), ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index d3570e6c23..1bf70095ff 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -1,8 +1,13 @@ package com.tangem.features.staking.impl.presentation.state import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub +import com.tangem.features.staking.impl.presentation.state.transformers.SetButtonsStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetTitleTransformer import com.tangem.utils.transformer.Transformer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,20 +25,30 @@ internal class StakingStateController @Inject constructor() { val uiState: StateFlow get() = mutableUiState.asStateFlow() + private val buttonsTransformer = SetButtonsStateTransformer() + private val titleTransformer = SetTitleTransformer + fun update(function: (StakingUiState) -> StakingUiState) { mutableUiState.update(function = function) + mutableUiState.update(function = buttonsTransformer::transform) + mutableUiState.update(function = titleTransformer::transform) } fun update(transformer: Transformer) { mutableUiState.update(function = transformer::transform) + mutableUiState.update(function = buttonsTransformer::transform) + mutableUiState.update(function = titleTransformer::transform) } fun clear() { mutableUiState.update { getInitialState() } + mutableUiState.update(function = buttonsTransformer::transform) + mutableUiState.update(function = titleTransformer::transform) } private fun getInitialState(): StakingUiState { return StakingUiState( + title = TextReference.EMPTY, clickIntents = StakingClickIntentsStub, cryptoCurrencyName = "", currentStep = StakingStep.InitialInfo, @@ -44,7 +59,8 @@ internal class StakingStateController @Inject constructor() { isBalanceHidden = false, event = consumedEvent(), bottomSheetConfig = null, - routeType = RouteType.STAKE, + actionType = StakingActionCommonType.ENTER, + buttonsState = NavigationButtonsState.Empty, ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt index 2629f6405b..03cc906908 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateRouter.kt @@ -1,6 +1,7 @@ package com.tangem.features.staking.impl.presentation.state import com.tangem.common.routing.AppRouter +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType internal class StakingStateRouter( private val appRouter: AppRouter, @@ -14,12 +15,12 @@ internal class StakingStateRouter( fun onNextClick() { when (stateController.value.currentStep) { - StakingStep.InitialInfo -> when (stateController.value.routeType) { - RouteType.STAKE -> showAmount() - RouteType.OTHER, - RouteType.UNSTAKE, + StakingStep.InitialInfo -> when (stateController.value.actionType) { + StakingActionCommonType.ENTER -> showAmount() + StakingActionCommonType.PENDING_OTHER, + StakingActionCommonType.EXIT, -> showConfirmation() - RouteType.CLAIM -> showRewardsValidators() + StakingActionCommonType.PENDING_REWARDS -> showRewardsValidators() } StakingStep.RewardsValidators, StakingStep.Validators, @@ -32,10 +33,17 @@ internal class StakingStateRouter( } fun onPrevClick() { - when (stateController.uiState.value.currentStep) { + val uiState = stateController.uiState.value + when (uiState.currentStep) { StakingStep.InitialInfo -> onBackClick() StakingStep.Amount -> showInitial() - StakingStep.Confirmation -> showAmount() + StakingStep.Confirmation -> { + if (uiState.actionType != StakingActionCommonType.ENTER) { + showInitial() + } else { + showAmount() + } + } StakingStep.Validators -> showConfirmation() StakingStep.RewardsValidators -> showInitial() } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index 2ad72821a5..e9b4c44faa 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -2,11 +2,13 @@ package com.tangem.features.staking.impl.presentation.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.transformers.InfoType import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import kotlinx.collections.immutable.ImmutableList @@ -16,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList */ @Immutable internal data class StakingUiState( + val title: TextReference, val clickIntents: StakingClickIntents, val cryptoCurrencyName: String, val currentStep: StakingStep, @@ -25,7 +28,8 @@ internal data class StakingUiState( val confirmationState: StakingStates.ConfirmationState, val isBalanceHidden: Boolean, val bottomSheetConfig: TangemBottomSheetConfig?, - val routeType: RouteType, + val actionType: StakingActionCommonType, + val buttonsState: NavigationButtonsState, val event: StateEvent, ) { @@ -55,17 +59,6 @@ internal sealed class StakingStates { val isStakeMoreAvailable: Boolean, ) : InitialInfoState() - data class InitialInfoItems( - val available: String, - val onStake: String, - val aprRange: TextReference, - val unbondingPeriod: String, - val minimumRequirement: String, - val rewardClaiming: String, - val warmupPeriod: String, - val rewardSchedule: String, - ) - data class Empty( override val isPrimaryButtonEnabled: Boolean = false, ) : InitialInfoState() @@ -94,6 +87,8 @@ internal sealed class StakingStates { val notifications: ImmutableList, val footerText: String, val transactionDoneState: TransactionDoneState, + val pendingActionInProgress: PendingAction? = null, + val isApprovalNeeded: Boolean, ) : ConfirmationState() data class Empty( @@ -108,11 +103,4 @@ enum class StakingStep { Amount, Confirmation, Validators, -} - -enum class RouteType { - STAKE, - UNSTAKE, - CLAIM, - OTHER, } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/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/ConfirmationStatePreviewData.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt index 0994dd7cdb..807e584ca3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/ConfirmationStatePreviewData.kt @@ -5,6 +5,7 @@ import com.tangem.blockchain.common.AmountType.Coin import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.* import com.tangem.features.staking.impl.presentation.state.StakingNotification import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -81,10 +82,11 @@ internal object ConfirmationStatePreviewData { notifications = persistentListOf( StakingNotification.Warning.EarnRewards( currencyName = "Solana", - days = 2, + subtitleResourceId = R.string.staking_notification_earn_rewards_text_period_day, ), ), transactionDoneState = TransactionDoneState.Empty, pendingActions = persistentListOf(), + isApprovalNeeded = false, ) } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index cca20b1416..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..c8e5a5d35a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -2,6 +2,7 @@ package com.tangem.features.staking.impl.presentation.state.stub import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.transformers.InfoType import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents @@ -11,10 +12,14 @@ object StakingClickIntentsStub : StakingClickIntents { override fun onBackClick() {} - override fun onNextClick(pendingActions: ImmutableList) {} + override fun onNextClick(actionType: StakingActionCommonType?, pendingActions: ImmutableList) {} + + override fun onActionClick(pendingAction: PendingAction?) {} override fun onPrevClick() {} + override fun onInitialInfoBannerClick() {} + override fun onInfoClick(infoType: InfoType) {} override fun onAmountValueChange(value: String) {} @@ -33,7 +38,9 @@ object StakingClickIntentsStub : StakingClickIntents { override fun openRewardsValidators() {} - override fun selectRewardValidator(rewardValue: String) {} + override fun showApprovalBottomSheet() {} + + override fun onApprovalClick() {} override fun onExploreClick() {} diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt new file mode 100644 index 0000000000..69278afeb6 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/AddStakingErrorTransformer.kt @@ -0,0 +1,36 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.staking.model.stakekit.StakingError +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class AddStakingErrorTransformer( + private val error: StakingError, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmationState = + prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState + + return prevState.copy( + confirmationState = confirmationState.copy( + notifications = (confirmationState.notifications + convertToNotification(error)).toPersistentList(), + feeState = FeeState.Error, + ), + ) + } + + private fun convertToNotification(error: StakingError): StakingNotification { + return when (error) { + is StakingError.StakedPositionNotFoundError -> StakingNotification.Error.StakedPositionNotFoundError( + message = error.toString(), + ) + // TODO staking + else -> StakingNotification.Error.Common( + subtitle = stringReference(error.toString()), + ) + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt new file mode 100644 index 0000000000..c954aee97d --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -0,0 +1,249 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationButtonsState +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class SetButtonsStateTransformer : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + + val buttonsState = if (prevState.isButtonsVisible()) { + NavigationButtonsState.Data( + primaryButton = getPrimaryButton(prevState), + prevButton = getPrevButton(prevState), + secondaryButton = getSecondaryButton(prevState), + extraButtons = getExtraButtons(prevState), + txUrl = (confirmState?.transactionDoneState as? TransactionDoneState.Content)?.txUrl, + ) + } else { + NavigationButtonsState.Empty + } + + return prevState.copy(buttonsState = buttonsState) + } + + private fun getPrimaryButton(prevState: StakingUiState): NavigationButton { + val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val innerConfirmState = confirmState?.innerState + + val isPrimaryInProgress = + confirmState?.pendingActions?.getPrimaryAction() == confirmState?.pendingActionInProgress + val isConfirmation = prevState.currentStep == StakingStep.Confirmation + val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS + val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED + + val isIconVisible = isConfirmation && !isCompleted + val isShowProgress = isInProgress && isPrimaryInProgress + return NavigationButton( + textReference = prevState.getButtonText(), + iconRes = R.drawable.ic_tangem_24, + isSecondary = false, + isIconVisible = isIconVisible, + showProgress = isShowProgress, + isEnabled = prevState.isButtonEnabled(), + onClick = { prevState.onPrimaryClick() }, + ) + } + + private fun getSecondaryButton(prevState: StakingUiState): NavigationButton? { + val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data + val innerConfirmState = confirmState?.innerState + + val isConfirmation = prevState.currentStep == StakingStep.Confirmation + val isInProgress = innerConfirmState == InnerConfirmationStakingState.IN_PROGRESS + val isCompleted = innerConfirmState == InnerConfirmationStakingState.COMPLETED + + return confirmState?.pendingActions?.getSecondaryAction()?.let { pendingAction -> + val isSecondaryInProgress = pendingAction == confirmState.pendingActionInProgress + val isShowProgress = isInProgress && isSecondaryInProgress + NavigationButton( + textReference = getPendingActionTitle(pendingAction.type), + iconRes = R.drawable.ic_tangem_24, + isSecondary = true, + isIconVisible = true, + showProgress = isShowProgress, + isEnabled = prevState.isButtonEnabled(), + onClick = { prevState.clickIntents.onActionClick(pendingAction) }, + ).takeIf { isConfirmation && !isCompleted } + } + } + + private fun getPrevButton(prevState: StakingUiState): NavigationButton? { + return NavigationButton( + textReference = TextReference.EMPTY, + iconRes = R.drawable.ic_back_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = prevState.clickIntents::onPrevClick, + ).takeIf { prevState.currentStep.isPrevButtonVisible() } + } + + private fun getExtraButtons(prevState: StakingUiState): ImmutableList { + return persistentListOf( + NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_web_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = prevState.clickIntents::onExploreClick, + ), + NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_share_24, + isSecondary = true, + isIconVisible = true, + showProgress = false, + isEnabled = true, + onClick = prevState.clickIntents::onShareClick, + ), + ) + } + + private fun List.getPrimaryAction(): PendingAction? = getOrNull(0) + + private fun List.getSecondaryAction(): PendingAction? = getOrNull(1) + + private fun StakingUiState.isButtonsVisible(): Boolean = when (currentStep) { + StakingStep.InitialInfo -> isStakeMoreAvailable() + StakingStep.RewardsValidators -> false + else -> true + } + + private fun StakingUiState.getButtonText(): TextReference { + return when (currentStep) { + StakingStep.InitialInfo -> { + val initialState = initialInfoState as? StakingStates.InitialInfoState.Data + if (initialState?.yieldBalance is InnerYieldBalanceState.Data) { + resourceReference(R.string.staking_stake_more) + } else { + resourceReference(R.string.common_next) + } + } + + StakingStep.Confirmation -> getConfirmationButtonText() + StakingStep.Validators -> resourceReference(R.string.common_continue) + StakingStep.Amount, + StakingStep.RewardsValidators, + -> resourceReference(R.string.common_next) + } + } + + private fun StakingUiState.getConfirmationButtonText(): TextReference { + return if (confirmationState is StakingStates.ConfirmationState.Data) { + if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { + resourceReference(R.string.common_close) + } else { + when (actionType) { + StakingActionCommonType.ENTER -> { + if (confirmationState.isApprovalNeeded) { + resourceReference(R.string.give_permission_title) + } else { + resourceReference(R.string.common_stake) + } + } + StakingActionCommonType.EXIT -> resourceReference(R.string.common_unstake) + StakingActionCommonType.PENDING_OTHER, + StakingActionCommonType.PENDING_REWARDS, + -> getPendingActionTitle(confirmationState.pendingActions.firstOrNull()?.type) + } + } + } else { + resourceReference(R.string.common_close) + } + } + + private fun StakingUiState.onPrimaryClick() { + when (currentStep) { + StakingStep.InitialInfo -> { + val actionType = StakingActionCommonType.ENTER.takeIf { isStakeMoreAvailable() } + clickIntents.onAmountValueChange("") // reset amount state + clickIntents.onNextClick(actionType) + } + StakingStep.Validators, + StakingStep.Amount, + -> clickIntents.onNextClick() + StakingStep.Confirmation -> onConfirmationClick() + StakingStep.RewardsValidators -> Unit + } + } + + private fun StakingUiState.onConfirmationClick() { + if (confirmationState is StakingStates.ConfirmationState.Data) { + if (confirmationState.innerState == InnerConfirmationStakingState.COMPLETED) { + clickIntents.onBackClick() + } else { + val isEnterAction = actionType == StakingActionCommonType.ENTER + val isApproveNeeded = confirmationState.isApprovalNeeded + + if (isEnterAction && isApproveNeeded) { + clickIntents.showApprovalBottomSheet() + } else { + clickIntents.onActionClick(confirmationState.pendingActions.firstOrNull()) + } + } + } else { + clickIntents.onBackClick() + } + } + + private fun StakingStep.isPrevButtonVisible(): Boolean = when (this) { + StakingStep.InitialInfo, + StakingStep.RewardsValidators, + StakingStep.Confirmation, + StakingStep.Validators, + -> false + StakingStep.Amount, + -> true + } + + private fun StakingUiState.isButtonEnabled(): Boolean { + return when (currentStep) { + StakingStep.InitialInfo -> initialInfoState.isPrimaryButtonEnabled + StakingStep.Amount -> amountState.isPrimaryButtonEnabled + StakingStep.Confirmation -> confirmationState.isPrimaryButtonEnabled + StakingStep.RewardsValidators -> rewardsValidatorsState.isPrimaryButtonEnabled + StakingStep.Validators -> true + } + } + + @Suppress("CyclomaticComplexMethod") + private fun getPendingActionTitle(type: StakingActionType?): TextReference = when (type) { + StakingActionType.CLAIM_REWARDS -> resourceReference(R.string.common_claim_rewards) + StakingActionType.RESTAKE_REWARDS -> resourceReference(R.string.staking_restake_rewards) + StakingActionType.WITHDRAW -> resourceReference(R.string.staking_withdraw) + StakingActionType.RESTAKE -> resourceReference(R.string.staking_restake) + StakingActionType.CLAIM_UNSTAKED -> resourceReference(R.string.staking_claim_unstaked) + StakingActionType.UNLOCK_LOCKED -> resourceReference(R.string.staking_unlocked_locked) + StakingActionType.STAKE_LOCKED -> resourceReference(R.string.staking_stake_locked) + StakingActionType.VOTE -> resourceReference(R.string.staking_vote) + StakingActionType.REVOKE -> resourceReference(R.string.staking_revoke) + StakingActionType.VOTE_LOCKED -> resourceReference(R.string.staking_vote_locked) + StakingActionType.REVOTE -> resourceReference(R.string.staking_revote) + StakingActionType.REBOND -> resourceReference(R.string.staking_rebond) + StakingActionType.MIGRATE -> resourceReference(R.string.staking_migrate) + StakingActionType.STAKE -> resourceReference(R.string.common_stake) + StakingActionType.UNSTAKE -> resourceReference(R.string.common_unstake) + StakingActionType.UNKNOWN -> TextReference.EMPTY + null -> TextReference.EMPTY + } + + private fun StakingUiState.isStakeMoreAvailable(): Boolean { + val initialState = initialInfoState as? StakingStates.InitialInfoState.Data + return initialState?.isStakeMoreAvailable == true || initialState?.yieldBalance is InnerYieldBalanceState.Empty + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt index f2dc22698e..33b475fcaf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt @@ -6,8 +6,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.features.staking.impl.presentation.state.FeeState -import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState +import com.tangem.features.staking.impl.presentation.state.* import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.Provider @@ -49,6 +48,7 @@ internal class SetConfirmationStateAssentTransformer( validatorState = validatorState.copySealed(isClickable = true), pendingActions = pendingActionList, isPrimaryButtonEnabled = true, + isApprovalNeeded = false, ) } else { return this diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt index e17aad84b4..817ff94491 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInProgressTransformer.kt @@ -1,11 +1,14 @@ package com.tangem.features.staking.impl.presentation.state.transformers +import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer -internal class SetConfirmationStateInProgressTransformer : Transformer { +internal class SetConfirmationStateInProgressTransformer( + private val pendingAction: PendingAction?, +) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( @@ -19,6 +22,7 @@ internal class SetConfirmationStateInProgressTransformer : Transformer { + return persistentListOf( + if (prevState.actionType == StakingActionCommonType.EXIT) { + StakingNotification.Warning.Unstake( + cooldownPeriodDays = yield.metadata.cooldownPeriod.days, + ) + } else { + StakingNotification.Warning.EarnRewards( + currencyName = yield.token.name, + subtitleResourceId = getEarnRewardsPeriod(yield.metadata.rewardSchedule), + ) + }, + ) + } + + private fun getEarnRewardsPeriod(rewardSchedule: Yield.Metadata.RewardSchedule): Int { + return when (rewardSchedule) { + Yield.Metadata.RewardSchedule.BLOCK, + Yield.Metadata.RewardSchedule.DAY, + Yield.Metadata.RewardSchedule.ERA, + Yield.Metadata.RewardSchedule.EPOCH, + -> R.string.staking_notification_earn_rewards_text_period_day + + Yield.Metadata.RewardSchedule.HOUR, + -> R.string.staking_notification_earn_rewards_text_period_hour + + Yield.Metadata.RewardSchedule.WEEK, + -> R.string.staking_notification_earn_rewards_text_period_week + + Yield.Metadata.RewardSchedule.MONTH, + -> R.string.staking_notification_earn_rewards_text_period_month + + else + -> R.string.staking_notification_earn_rewards_text_period_day + } + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 5dba67759a..dc3785b501 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -5,14 +5,11 @@ import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.list.RoundedListWithDividersItemData -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.core.serialization.SerializedBigDecimal import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.staking.impl.R @@ -25,10 +22,9 @@ import com.tangem.features.staking.impl.presentation.state.converters.YieldBalan import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import com.tangem.utils.Provider -import com.tangem.utils.extensions.orZero import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal internal class SetInitialDataStateTransformer( @@ -62,6 +58,11 @@ internal class SetInitialDataStateTransformer( override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( + title = TextReference.Res( + R.string.staking_title_stake, + wrappedList(cryptoCurrencyStatusProvider().currency.name), + ), + cryptoCurrencyName = cryptoCurrencyStatusProvider.invoke().currency.name, clickIntents = clickIntents, currentStep = StakingStep.InitialInfo, initialInfoState = createInitialInfoState(), @@ -75,7 +76,7 @@ internal class SetInitialDataStateTransformer( private fun createInitialInfoState(): StakingStates.InitialInfoState.Data { return StakingStates.InitialInfoState.Data( isPrimaryButtonEnabled = true, - aprRange = getAprRange(), + aprRange = getAprRange(yield.validators), infoItems = getInfoItems(), onInfoClick = clickIntents::onInfoClick, yieldBalance = yieldBalancesConverter.convert(Unit), @@ -85,72 +86,114 @@ internal class SetInitialDataStateTransformer( private fun getInfoItems(): PersistentList { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() - val yieldBalance = cryptoCurrencyStatus.value.yieldBalance - return persistentListOf( - RoundedListWithDividersItemData( - id = R.string.staking_details_available, - startText = TextReference.Res(R.string.staking_details_available), - endText = TextReference.Str( - value = BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = cryptoCurrencyStatus.value.amount, - cryptoCurrency = cryptoCurrencyStatus.currency.symbol, - decimals = cryptoCurrencyStatus.currency.decimals, - ), + return listOfNotNull( + createAnnualPercentageRateItem(yield.validators), + createAvailableItem(cryptoCurrencyStatus), + createUnbondingPeriodItem(yield.metadata.cooldownPeriod.days), + createMinimumRequirementItem( + cryptoCurrencyStatus = cryptoCurrencyStatus, + minimumCryptoAmount = yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum, + ), + createRewardClaimingItem(yield.metadata.rewardClaiming), + createWarmupPeriodItem(yield.metadata.warmupPeriod.days), + createRewardScheduleItem(yield.metadata.rewardSchedule), + ).toPersistentList() + } + + private fun createAnnualPercentageRateItem(validators: List): RoundedListWithDividersItemData { + return RoundedListWithDividersItemData( + id = R.string.staking_details_annual_percentage_rate, + startText = TextReference.Res(R.string.staking_details_annual_percentage_rate), + endText = getAprRange(validators), + iconClick = { clickIntents.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) }, + ) + } + + private fun createAvailableItem(cryptoCurrencyStatus: CryptoCurrencyStatus): RoundedListWithDividersItemData { + return RoundedListWithDividersItemData( + id = R.string.staking_details_available, + startText = TextReference.Res(R.string.staking_details_available), + endText = TextReference.Str( + value = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = cryptoCurrencyStatus.value.amount, + cryptoCurrency = cryptoCurrencyStatus.currency.symbol, + decimals = cryptoCurrencyStatus.currency.decimals, ), ), - RoundedListWithDividersItemData( - id = R.string.staking_details_annual_percentage_rate, - startText = TextReference.Res(R.string.staking_details_annual_percentage_rate), - endText = getAprRange(), - iconClick = { clickIntents.onInfoClick(InfoType.APY) }, + ) + } + + private fun createUnbondingPeriodItem(cooldownPeriodDays: Int): RoundedListWithDividersItemData { + return RoundedListWithDividersItemData( + id = R.string.staking_details_unbonding_period, + startText = TextReference.Res(R.string.staking_details_unbonding_period), + endText = pluralReference( + id = R.plurals.common_days, + count = cooldownPeriodDays, + formatArgs = wrappedList(cooldownPeriodDays), ), - RoundedListWithDividersItemData( - id = 0, // todo remove in merge - startText = TextReference.Res(0), // todo remove in merge - endText = TextReference.Str( - value = BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = (yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero(), - cryptoCurrency = cryptoCurrencyStatus.currency.symbol, - decimals = cryptoCurrencyStatus.currency.decimals, - ), + iconClick = { clickIntents.onInfoClick(InfoType.UNBONDING_PERIOD) }, + ) + } + + private fun createMinimumRequirementItem( + cryptoCurrencyStatus: CryptoCurrencyStatus, + minimumCryptoAmount: SerializedBigDecimal?, + ): RoundedListWithDividersItemData? { + minimumCryptoAmount ?: return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_minimum_requirement, + startText = TextReference.Res(R.string.staking_details_minimum_requirement), + endText = TextReference.Str( + value = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = minimumCryptoAmount, + cryptoCurrency = cryptoCurrencyStatus.currency.symbol, + decimals = cryptoCurrencyStatus.currency.decimals, ), ), - RoundedListWithDividersItemData( - id = R.string.staking_details_unbonding_period, - startText = TextReference.Res(R.string.staking_details_unbonding_period), - endText = TextReference.Str(yield.metadata.cooldownPeriod.days.toString()), - iconClick = { clickIntents.onInfoClick(InfoType.UNBOUNDING_PERIOD) }, - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_minimum_requirement, - startText = TextReference.Res(R.string.staking_details_minimum_requirement), - endText = TextReference.Str( - value = BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = yield.args.enter.args[KEY_AMOUNT]?.minimum?.toBigDecimal(), - cryptoCurrency = cryptoCurrencyStatus.currency.symbol, - decimals = cryptoCurrencyStatus.currency.decimals, - ), - ), - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_reward_claiming, - startText = TextReference.Res(R.string.staking_details_reward_claiming), - endText = TextReference.Str(yield.metadata.rewardClaiming), - iconClick = { clickIntents.onInfoClick(InfoType.REWARD_CLAIMING) }, - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_warmup_period, - startText = TextReference.Res(R.string.staking_details_warmup_period), - endText = TextReference.Str(yield.metadata.warmupPeriod.days.toString()), - iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) }, - ), - RoundedListWithDividersItemData( - id = R.string.staking_details_reward_schedule, - startText = TextReference.Res(R.string.staking_details_reward_schedule), - endText = TextReference.Str(yield.metadata.rewardSchedule), - iconClick = { clickIntents.onInfoClick(InfoType.REWARD_SCHEDULE) }, + ) + } + + private fun createRewardClaimingItem( + rewardClaiming: Yield.Metadata.RewardClaiming, + ): RoundedListWithDividersItemData? { + val endTextId = rewardClaimingResources[rewardClaiming] ?: return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_reward_claiming, + startText = TextReference.Res(R.string.staking_details_reward_claiming), + endText = TextReference.Res(endTextId), + iconClick = { clickIntents.onInfoClick(InfoType.REWARD_CLAIMING) }, + ) + } + + private fun createWarmupPeriodItem(warmupPeriodDays: Int): RoundedListWithDividersItemData? { + if (warmupPeriodDays == 0) return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_warmup_period, + startText = TextReference.Res(R.string.staking_details_warmup_period), + endText = pluralReference( + id = R.plurals.common_days, + count = warmupPeriodDays, + formatArgs = wrappedList(warmupPeriodDays), ), + iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) }, + ) + } + + private fun createRewardScheduleItem( + rewardSchedule: Yield.Metadata.RewardSchedule, + ): RoundedListWithDividersItemData? { + val endTextId = rewardScheduleResources[rewardSchedule] ?: return null + + return RoundedListWithDividersItemData( + id = R.string.staking_details_reward_schedule, + startText = TextReference.Res(R.string.staking_details_reward_schedule), + endText = TextReference.Res(endTextId), + iconClick = { clickIntents.onInfoClick(InfoType.REWARD_SCHEDULE) }, ) } @@ -168,8 +211,8 @@ internal class SetInitialDataStateTransformer( ) } - private fun getAprRange(): TextReference { - val aprValues = yield.validators.mapNotNull { it.apr } + private fun getAprRange(validators: List): TextReference { + val aprValues = validators.mapNotNull { it.apr } val minApr = aprValues.min() val maxApr = aprValues.max() @@ -191,6 +234,22 @@ internal class SetInitialDataStateTransformer( companion object { private val EQUALITY_THRESHOLD = BigDecimal(1E-10) - private const val KEY_AMOUNT = "amount" + + private val rewardScheduleResources = mapOf( + Yield.Metadata.RewardSchedule.BLOCK to R.string.staking_reward_schedule_each_day, + Yield.Metadata.RewardSchedule.WEEK to R.string.staking_reward_schedule_week, + Yield.Metadata.RewardSchedule.HOUR to R.string.staking_reward_schedule_hour, + Yield.Metadata.RewardSchedule.DAY to R.string.staking_reward_schedule_each_day, + Yield.Metadata.RewardSchedule.MONTH to R.string.staking_reward_schedule_month, + Yield.Metadata.RewardSchedule.ERA to R.string.staking_reward_schedule_era, + Yield.Metadata.RewardSchedule.EPOCH to R.string.staking_reward_schedule_epoch, + Yield.Metadata.RewardSchedule.UNKNOWN to null, + ) + + private val rewardClaimingResources = mapOf( + Yield.Metadata.RewardClaiming.MANUAL to R.string.staking_reward_claiming_manual, + Yield.Metadata.RewardClaiming.AUTO to R.string.staking_reward_claiming_auto, + Yield.Metadata.RewardSchedule.UNKNOWN to null, + ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt new file mode 100644 index 0000000000..ffb66a5ee1 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetTitleTransformer.kt @@ -0,0 +1,41 @@ +package com.tangem.features.staking.impl.presentation.state.transformers + +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.utils.transformer.Transformer + +internal object SetTitleTransformer : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + val actionType = prevState.actionType + val currentStep = prevState.currentStep + + val title = when { + currentStep == StakingStep.Amount -> { + resourceReference(R.string.send_amount_label) + } + + currentStep == StakingStep.Validators -> { + resourceReference(R.string.staking_validators) + } + + actionType == StakingActionCommonType.EXIT && currentStep != StakingStep.InitialInfo -> { + resourceReference( + R.string.staking_title_unstake, + wrappedList(prevState.cryptoCurrencyName), + ) + } + else -> { + resourceReference( + R.string.staking_title_stake, + wrappedList(prevState.cryptoCurrencyName), + ) + } + } + + return prevState.copy(title = title) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt index 05db6fb7ba..06f89bdd94 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/ShowInfoBottomSheetStateTransformer.kt @@ -18,11 +18,11 @@ internal class ShowInfoBottomSheetStateTransformer( onDismissRequest = onDismiss, isShow = true, content = when (infoType) { - InfoType.APY -> StakingInfoBottomSheetConfig( + InfoType.ANNUAL_PERCENTAGE_RATE -> StakingInfoBottomSheetConfig( title = resourceReference(R.string.staking_details_annual_percentage_rate), text = resourceReference(R.string.staking_details_annual_percentage_rate_info), ) - InfoType.UNBOUNDING_PERIOD -> StakingInfoBottomSheetConfig( + InfoType.UNBONDING_PERIOD -> StakingInfoBottomSheetConfig( title = resourceReference(R.string.staking_details_unbonding_period), text = resourceReference(R.string.staking_details_unbonding_period_info), ) @@ -45,8 +45,8 @@ internal class ShowInfoBottomSheetStateTransformer( } enum class InfoType { - APY, - UNBOUNDING_PERIOD, + ANNUAL_PERCENTAGE_RATE, + UNBONDING_PERIOD, REWARD_CLAIMING, WARMUP_PERIOD, REWARD_SCHEDULE, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt index 110de484fc..b87f8a0251 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountChangeStateTransformer.kt @@ -1,18 +1,31 @@ package com.tangem.features.staking.impl.presentation.state.transformers.amount import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer internal class AmountChangeStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val yield: Yield, private val value: String, ) : Transformer { + private val amountRequirementStateTransformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus, + yield, + value, + ) + override fun transform(prevState: StakingUiState): StakingUiState { + val updatedAmountState = AmountFieldChangeTransformer( + cryptoCurrencyStatus, + value, + ).transform(prevState.amountState) + return prevState.copy( - amountState = AmountFieldChangeTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState), + amountState = amountRequirementStateTransformer.transform(updatedAmountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt index 3d7dc9125d..ae53eab63e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountMaxValueStateTransformer.kt @@ -1,16 +1,29 @@ package com.tangem.features.staking.impl.presentation.state.transformers.amount import com.tangem.common.ui.amountScreen.converters.field.AmountFieldMaxAmountTransformer +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.utils.transformer.Transformer internal class AmountMaxValueStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val yield: Yield, ) : Transformer { + + private val amountRequirementStateTransformer = AmountRequirementStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + yield = yield, + value = cryptoCurrencyStatus.value.amount + ?.parseBigDecimal(cryptoCurrencyStatus.currency.decimals) + .orEmpty(), + ) + override fun transform(prevState: StakingUiState): StakingUiState { + val updatedAmountState = AmountFieldMaxAmountTransformer(cryptoCurrencyStatus).transform(prevState.amountState) return prevState.copy( - amountState = AmountFieldMaxAmountTransformer(cryptoCurrencyStatus).transform(prevState.amountState), + amountState = amountRequirementStateTransformer.transform(updatedAmountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt new file mode 100644 index 0000000000..f16597ae54 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountRequirementStateTransformer.kt @@ -0,0 +1,65 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.amount + +import com.tangem.common.extensions.isZero +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.staking.model.stakekit.AddressArgument +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.R +import com.tangem.utils.transformer.Transformer + +internal class AmountRequirementStateTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val yield: Yield, + private val value: String, +) : Transformer { + override fun transform(prevState: AmountState): AmountState { + val amountRequirements = yield.args.enter.args[Yield.Args.ArgType.AMOUNT] + + return if (prevState !is AmountState.Data || amountRequirements == null) { + prevState + } else { + updateWithError(prevState, amountRequirements) + } + } + + private fun updateWithError(prevState: AmountState.Data, amountRequirements: AddressArgument): AmountState { + val isRequirementError = isRequirementError(prevState, amountRequirements) + return if (isRequirementError) { + prevState.copy( + amountTextField = prevState.amountTextField.copy( + isError = true, + error = resourceReference( + R.string.staking_amount_requirement_error, + wrappedList( + BigDecimalFormatter.formatCryptoAmount( + amountRequirements.minimum, + cryptoCurrencyStatus.currency.symbol, + cryptoCurrencyStatus.currency.decimals, + ), + ), + ), + ), + ) + } else { + prevState + } + } + + private fun isRequirementError(prevState: AmountState.Data, amountRequirements: AddressArgument): Boolean { + val amountDecimal = value.parseToBigDecimal(cryptoCurrencyStatus.currency.decimals) + + val isAlreadyErrorState = prevState.amountTextField.isError + val isAmountRequired = amountRequirements.required + val isAmountZero = amountDecimal.isZero() + val isExceedsRequirements = + amountRequirements.maximum?.compareTo(amountDecimal) == -1 || + amountRequirements.minimum?.compareTo(amountDecimal) == 1 + + return !isAmountZero && isAmountRequired && isExceedsRequirements && !isAlreadyErrorState + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt new file mode 100644 index 0000000000..085061c2b8 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt @@ -0,0 +1,34 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.approval + +import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.transformer.Transformer + +internal class SetApprovalBottomSheetInProgressTransformer( + private val onDismiss: () -> Unit, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val approvalBottomSheetConfig = prevState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig + return prevState.copy( + bottomSheetConfig = prevState.bottomSheetConfig?.copy( + onDismissRequest = onDismiss, + isShow = true, + content = approvalBottomSheetConfig?.let { config -> + config.copy( + data = config.data.copy( + approveButton = config.data.approveButton.copy( + enabled = false, + loading = true, + ), + cancelButton = config.data.cancelButton.copy( + enabled = false, + ), + ), + onCancel = onDismiss, + ) + } as TangemBottomSheetConfigContent, + ), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetConfirmationStateAssentApprovalTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetConfirmationStateAssentApprovalTransformer.kt new file mode 100644 index 0000000000..c3962befb2 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetConfirmationStateAssentApprovalTransformer.kt @@ -0,0 +1,45 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.approval + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.utils.Provider +import com.tangem.utils.transformer.Transformer + +internal class SetConfirmationStateAssentApprovalTransformer( + private val appCurrencyProvider: Provider, + private val cryptoCurrencyStatusProvider: Provider, + private val fee: TransactionFee, +) : Transformer { + + override fun transform(prevState: StakingUiState): StakingUiState { + return prevState.copy( + confirmationState = prevState.confirmationState.copyWrapped(), + bottomSheetConfig = null, + ) + } + + private fun StakingStates.ConfirmationState.copyWrapped(): StakingStates.ConfirmationState { + return if (this is StakingStates.ConfirmationState.Data) { + copy( + innerState = InnerConfirmationStakingState.ASSENT, + feeState = FeeState.Content( + fee = fee.normal, + rate = cryptoCurrencyStatusProvider().value.fiatRate, + isFeeConvertibleToFiat = cryptoCurrencyStatusProvider().currency.network.hasFiatFeeRate, + appCurrency = appCurrencyProvider(), + isFeeApproximate = false, + ), + validatorState = validatorState.copySealed(isClickable = true), + isPrimaryButtonEnabled = true, + isApprovalNeeded = true, + ) + } else { + this + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt new file mode 100644 index 0000000000..9eb4e68640 --- /dev/null +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt @@ -0,0 +1,78 @@ +package com.tangem.features.staking.impl.presentation.state.transformers.approval + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.bottomsheet.permission.state.* +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.state.FeeState +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.ValidatorState +import com.tangem.utils.Provider +import com.tangem.utils.transformer.Transformer + +internal class ShowApprovalBottomSheetTransformer( + private val appCurrencyProvider: Provider, + private val cryptoCurrencyStatusProvider: Provider, + private val onDismiss: () -> Unit, +) : Transformer { + override fun transform(prevState: StakingUiState): StakingUiState { + val cryptoCurrency = cryptoCurrencyStatusProvider().currency + val cryptoCurrencyValue = cryptoCurrencyStatusProvider().value + + val amountState = prevState.amountState as? AmountState.Data ?: return prevState + val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState + val validatorState = confirmationState.validatorState as? ValidatorState.Content ?: return prevState + val feeState = confirmationState.feeState as? FeeState.Content ?: return prevState + + val walletAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value.orEmpty() + val validatorAddress = validatorState.chosenValidator.address + val feeCryptoValue = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = feeState.fee?.amount?.value, + cryptoCurrency = cryptoCurrency, + ) + val feeFiatValue = BigDecimalFormatter.formatFiatAmount( + fiatAmount = feeState.fee?.amount?.maxValue, + fiatCurrencyCode = appCurrencyProvider().code, + fiatCurrencySymbol = appCurrencyProvider().symbol, + ) + return prevState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = GiveTxPermissionBottomSheetConfig( + data = GiveTxPermissionState.ReadyForRequest( + currency = cryptoCurrency.symbol, + amount = amountState.amountTextField.value, + approveType = ApproveType.LIMITED, + walletAddress = walletAddress, + spenderAddress = validatorAddress, + fee = resourceReference( + R.string.common_crypto_fiat_format, + wrappedList(feeCryptoValue, feeFiatValue), + ), + approveButton = ApprovePermissionButton( + enabled = true, + loading = false, + onClick = prevState.clickIntents::onApprovalClick, + ), + cancelButton = CancelPermissionButton( + enabled = true, + ), + subtitle = resourceReference( + id = R.string.give_permission_staking_subtitle, + formatArgs = wrappedList(cryptoCurrency.symbol), + ), + dialogText = resourceReference(R.string.give_permission_staking_footer), + ), + onCancel = onDismiss, + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingClaimRewardsValidatorContent.kt index 64a2f51b56..49d711da2d 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 @@ -31,7 +31,7 @@ internal fun StakingClaimRewardsValidatorContent( if (state !is StakingStates.RewardsValidatorsState.Data) return Column( modifier = Modifier // Do not put fillMaxSize() in here - .background(TangemTheme.colors.background.tertiary) + .background(TangemTheme.colors.background.secondary) .padding(horizontal = TangemTheme.dimens.spacing12) .verticalScroll(rememberScrollState()), ) { @@ -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..ae5079b938 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt @@ -18,8 +18,8 @@ import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.components.transactions.TransactionDoneTitle import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.RouteType import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.TransactionDoneState import com.tangem.features.staking.impl.presentation.state.previewdata.ConfirmationStatePreviewData @@ -34,15 +34,14 @@ internal fun StakingConfirmationContent( amountState: AmountState, state: StakingStates.ConfirmationState, clickIntents: StakingClickIntents, - type: RouteType, + type: StakingActionCommonType, ) { if (state !is StakingStates.ConfirmationState.Data) return Column( modifier = Modifier - .fillMaxSize() - .background(TangemTheme.colors.background.tertiary) - .padding(TangemTheme.dimens.spacing16) + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), ) { @@ -62,7 +61,7 @@ internal fun StakingConfirmationContent( isEditingDisabled = true, onClick = {}, ) - if (type == RouteType.STAKE) { + if (type == StakingActionCommonType.ENTER) { ValidatorBlock(validatorState = state.validatorState, onClick = clickIntents::openValidators) } StakingFeeBlock(feeState = state.feeState) @@ -93,7 +92,7 @@ private fun Preview_StakingConfirmationContent() { amountState = AmountStatePreviewData.amountState, state = ConfirmationStatePreviewData.assentStakingState, clickIntents = StakingClickIntentsStub, - type = RouteType.STAKE, + type = StakingActionCommonType.ENTER, ) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index 859bc837c1..810f49be28 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,122 @@ 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.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.containers.FooterContainer import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.components.inputrow.InputRowImageInfo -import com.tangem.core.ui.components.list.RoundedListWithDividers +import com.tangem.core.ui.components.list.roundedListWithDividersItems import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.BalanceGroupedState +import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState +import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.previewdata.InitialStakingStatePreview import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub import com.tangem.features.staking.impl.presentation.viewmodel.StakingClickIntents import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.StringsSigns.PLUS import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.ImmutableList +private const val BANNER_BLOCK_KEY = "BannerBlock" +private const val STAKING_REWARD_BLOCK_KEY = "StakingRewardBlock" +private const val ACTIVE_STAKING_BLOCK_KEY = "ActiveStakingBlock" + +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun StakingInitialInfoContent(state: StakingStates.InitialInfoState, clickIntents: StakingClickIntents) { if (state !is StakingStates.InitialInfoState.Data) return - Column( - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), - modifier = Modifier // Do not put fillMaxSize() in here - .background(TangemTheme.colors.background.tertiary) - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing16, - ), + LazyColumn( + modifier = Modifier + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16), ) { - AnimatedVisibility(state.yieldBalance == InnerYieldBalanceState.Empty) { - MetricsBlock(state) - } - RoundedListWithDividers(state.infoItems) - AnimatedContent(targetState = state.yieldBalance, label = "Rewards block visibility animation") { - if (it is InnerYieldBalanceState.Data) { - StakingRewardBlock( - rewardCrypto = it.rewardsCrypto, - rewardFiat = it.rewardsFiat, - isRewardsToClaim = it.isRewardsToClaim, - onRewardsClick = clickIntents::openRewardsValidators, - ) + if (state.yieldBalance == InnerYieldBalanceState.Empty) { + item(key = BANNER_BLOCK_KEY) { + Column( + modifier = Modifier.animateItemPlacement(), + ) { + BannerBlock(onClick = clickIntents::onInitialInfoBannerClick) + SpacerH12() + } } } - AnimatedContent(targetState = state.yieldBalance, label = "Rewards block visibility animation") { - if (it is InnerYieldBalanceState.Data) { - ActiveStakingBlock(it.balance, clickIntents::onActiveStake) + + this.roundedListWithDividersItems( + rows = state.infoItems, + footerContent = { SpacerH12() }, + ) + + if (state.yieldBalance is InnerYieldBalanceState.Data) { + item(key = STAKING_REWARD_BLOCK_KEY) { + Column(modifier = Modifier.animateItemPlacement()) { + StakingRewardBlock( + rewardCrypto = state.yieldBalance.rewardsCrypto, + rewardFiat = state.yieldBalance.rewardsFiat, + isRewardsToClaim = state.yieldBalance.isRewardsToClaim, + onRewardsClick = clickIntents::openRewardsValidators, + ) + SpacerH12() + } + } + } + + if (state.yieldBalance is InnerYieldBalanceState.Data) { + item(key = ACTIVE_STAKING_BLOCK_KEY) { + Column(modifier = Modifier.animateItemPlacement()) { + ActiveStakingBlock(state.yieldBalance.balance, clickIntents::onActiveStake) + SpacerH12() + } } } } } @Composable -private fun MetricsBlock(state: StakingStates.InitialInfoState.Data) { - Column( +private fun BannerBlock(onClick: () -> Unit) { + Box( modifier = Modifier - .background( - color = TangemTheme.colors.background.primary, - shape = RoundedCornerShape(TangemTheme.dimens.radius12), - ) - .padding(TangemTheme.dimens.spacing12) - .fillMaxWidth(), + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(), + onClick = onClick, + ), ) { - Text( - text = stringResource(id = R.string.staking_details_metrics_block_header), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, + Image( + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillWidth, + painter = painterResource(R.drawable.img_staking_banner), + contentDescription = null, ) - Spacer(modifier = Modifier.height(TangemTheme.dimens.size8)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1F)) { - Text( - text = stringResource(id = R.string.staking_details_apr), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), - text = state.aprRange.resolveReference(), - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.accent, - ) - } - Column(modifier = Modifier.weight(1F)) { - Row { - Text( - modifier = Modifier.padding(end = TangemTheme.dimens.spacing4), - text = stringResource(id = R.string.staking_details_market_rating), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - Icon( - modifier = Modifier - .size(TangemTheme.dimens.size16) - .align(Alignment.CenterVertically), - painter = painterResource(id = R.drawable.ic_alert_24), - contentDescription = null, - tint = TangemTheme.colors.text.tertiary, - ) + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(TangemTheme.dimens.spacing16), + text = buildAnnotatedString { + withStyle(SpanStyle(Brush.linearGradient(textGradientColors))) { + append(stringResource(R.string.staking_details_banner_text)) } - Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), - text = "1", // TODO staking - style = TangemTheme.typography.body1, - color = TangemTheme.colors.text.accent, - ) - } - } + }, + style = TangemTheme.typography.h2, + ) } } @@ -160,7 +158,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 +166,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 +191,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 +214,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 +241,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..ef94fa0c1d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -11,10 +11,13 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import com.tangem.common.ui.amountScreen.AmountScreenContent +import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet +import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig +import com.tangem.common.ui.navigationButtons.NavigationButtonsBlock import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -29,10 +32,10 @@ import kotlinx.coroutines.flow.withIndex @Composable internal fun StakingScreen(uiState: StakingUiState) { - BackHandler(onBack = uiState.clickIntents::onBackClick) + BackHandler(onBack = uiState.clickIntents::onPrevClick) Column( modifier = Modifier - .background(color = TangemTheme.colors.background.tertiary) + .background(color = TangemTheme.colors.background.secondary) .fillMaxSize() .imePadding() .systemBarsPadding(), @@ -45,8 +48,13 @@ internal fun StakingScreen(uiState: StakingUiState) { uiState = uiState, modifier = Modifier.weight(1f), ) - StakingNavigationButtons( - uiState = uiState, + NavigationButtonsBlock( + buttonState = uiState.buttonsState, + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), ) StakingBottomSheet(bottomSheetConfig = uiState.bottomSheetConfig) } @@ -57,19 +65,12 @@ fun StakingBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) { if (bottomSheetConfig == null) return when (bottomSheetConfig.content) { is StakingInfoBottomSheetConfig -> StakingInfoBottomSheet(bottomSheetConfig) + is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(bottomSheetConfig) } } @Composable private fun SendAppBar(uiState: StakingUiState) { - val titleRes = when (uiState.currentStep) { - StakingStep.Amount -> stringResource(id = R.string.send_amount_label) - StakingStep.InitialInfo, - StakingStep.RewardsValidators, - StakingStep.Validators, - StakingStep.Confirmation, - -> stringResource(id = R.string.common_stake) - } val backIcon = when (uiState.currentStep) { StakingStep.Amount, StakingStep.Validators, @@ -84,10 +85,10 @@ private fun SendAppBar(uiState: StakingUiState) { } } AppBarWithBackButtonAndIcon( - text = titleRes, + text = uiState.title.resolveReference(), backIconRes = backIcon, onBackClick = uiState.clickIntents::onBackClick, - backgroundColor = TangemTheme.colors.background.tertiary, + backgroundColor = TangemTheme.colors.background.secondary, modifier = Modifier.height(TangemTheme.dimens.size56), ) } @@ -155,7 +156,7 @@ private fun StakingScreenContent(uiState: StakingUiState, modifier: Modifier = M amountState = uiState.amountState, state = uiState.confirmationState, clickIntents = uiState.clickIntents, - type = uiState.routeType, + type = uiState.actionType, ) StakingStep.Validators -> { val confirmState = uiState.confirmationState diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt index 743979fa67..fe98e24c83 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingValidatorListContent.kt @@ -4,12 +4,10 @@ import android.content.res.Configuration 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.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle @@ -47,23 +45,6 @@ internal fun StakingValidatorListContent( contentPadding = PaddingValues(bottom = bottomBarHeight), modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing16), ) { - item(key = "HEADER") { - Text( - text = stringResource(R.string.staking_validator), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier - .fillMaxWidth() - .clip(CornersToRound.TOP_2.getShape()) - .background(TangemTheme.colors.background.action) - .padding( - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing8, - ), - ) - } if (state is ValidatorState.Content) { val validators = state.availableValidators items( @@ -93,10 +74,10 @@ internal fun StakingValidatorListContent( onSelect = { clickIntents.onValidatorSelect(item) }, modifier = Modifier .clip( - if (index == validators.lastIndex) { - CornersToRound.BOTTOM_2 - } else { - CornersToRound.ZERO + when (index) { + 0 -> CornersToRound.TOP_2 + validators.lastIndex -> CornersToRound.BOTTOM_2 + else -> CornersToRound.ZERO }.getShape(), ) .background(TangemTheme.colors.background.action), 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..e710b6b0b7 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt @@ -16,14 +16,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.R +import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.rows.SelectorRowItem -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.BigDecimalFormatter -import com.tangem.common.ui.R -import com.tangem.common.ui.amountScreen.utils.getCryptoReference -import com.tangem.common.ui.amountScreen.utils.getFiatReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.features.staking.impl.presentation.state.FeeState import java.math.BigDecimal @@ -39,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, ) @@ -53,7 +53,14 @@ internal fun StakingFeeBlock(feeState: FeeState) { SelectorRowItem( titleRes = title, iconRes = icon, - preDot = getCryptoReference(feeAmount, feeState.isFeeApproximate), + preDot = stringReference( + BigDecimalFormatter.formatCryptoFeeAmount( + cryptoAmount = feeAmount?.value, + cryptoCurrency = feeAmount?.currencySymbol.orEmpty(), + decimals = feeAmount?.decimals ?: 0, + canBeLower = feeState.isFeeApproximate, + ), + ), postDot = if (feeState.isFeeConvertibleToFiat) { getFiatReference(feeAmount?.value, feeState.rate, feeState.appCurrency) } else { @@ -88,7 +95,7 @@ private fun BoxScope.FeeLoading(feeState: FeeState) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( - height = TangemTheme.dimens.size12, + height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) @@ -107,7 +114,7 @@ private fun BoxScope.FeeError(feeState: FeeState) { Text( text = BigDecimalFormatter.EMPTY_BALANCE_SIGN, color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.body2, + style = TangemTheme.typography.body1, ) } } @@ -117,7 +124,7 @@ private fun BoxScope.FeeError(feeState: FeeState) { @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState.Content) { +private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState) { TangemThemePreview { StakingFeeBlock( feeState = value, @@ -125,11 +132,13 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va } } -private class FeeBlockPreviewProvider : PreviewParameterProvider { +private class FeeBlockPreviewProvider : PreviewParameterProvider { - override val values: Sequence + override val values: Sequence get() = sequenceOf( - feeState, + contentState, + FeeState.Loading, + FeeState.Error, ) private val fee = Fee.Common( @@ -141,7 +150,7 @@ private class FeeBlockPreviewProvider : PreviewParameterProvider = persistentListOf()) + fun onNextClick( + actionType: StakingActionCommonType? = null, + pendingActions: ImmutableList = persistentListOf(), + ) + + fun onActionClick(pendingAction: PendingAction?) fun onPrevClick() + fun onInitialInfoBannerClick() + fun onInfoClick(infoType: InfoType) - override fun onAmountNext() = onNextClick() + override fun onAmountNext() = onNextClick(actionType = null) fun openValidators() @@ -26,10 +34,12 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun openRewardsValidators() - fun selectRewardValidator(rewardValue: String) - fun onActiveStake(activeStake: BalanceState) + fun showApprovalBottomSheet() + + fun onApprovalClick() + fun onExploreClick() fun onShareClick() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt index 1aa55e58c3..c4c0bac907 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/viewmodel/StakingViewModel.kt @@ -7,22 +7,31 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData -import com.tangem.common.extensions.hexToBytes +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRoute import com.tangem.common.routing.bundle.unbundle import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.staking.* +import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.stakekit.PendingAction import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.ActionParams import com.tangem.domain.staking.model.stakekit.transaction.StakingGasEstimate +import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionType import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase +import com.tangem.domain.transaction.usecase.GetAllowanceUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.models.UserWallet @@ -35,14 +44,20 @@ import com.tangem.features.staking.impl.presentation.state.transformers.amount.A import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountCurrencyChangeStateTransformer import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountMaxValueStateTransformer import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountPasteDismissStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetConfirmationStateAssentApprovalTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import timber.log.Timber +import java.math.BigDecimal import javax.inject.Inject import kotlin.properties.Delegates @@ -56,20 +71,27 @@ internal class StakingViewModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val getStakingTransactionUseCase: GetStakingTransactionUseCase, + private val getConstructedStakingTransactionUseCase: GetConstructedStakingTransactionUseCase, private val estimateGasUseCase: EstimateGasUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, private val submitHashUseCase: SubmitHashUseCase, private val isStakeMoreAvailableUseCase: IsStakeMoreAvailableUseCase, + private val stakingYieldBalanceUseCase: FetchStakingYieldBalanceUseCase, + private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, + private val getAllowanceUseCase: GetAllowanceUseCase, + private val getFeeUseCase: GetFeeUseCase, + private val isApproveNeededUseCase: IsApproveNeededUseCase, + private val clipboardManager: ClipboardManager, + private val vibratorHapticManager: VibratorHapticManager, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver, StakingClickIntents { val uiState: StateFlow = stateController.uiState val value: StakingUiState get() = uiState.value - var stakingStateRouter: StakingStateRouter by Delegates.notNull() - private set + private var stakingStateRouter: StakingStateRouter by Delegates.notNull() private val cryptoCurrencyId: CryptoCurrency.ID = savedStateHandle.get(AppRoute.Staking.CRYPTO_CURRENCY_ID_KEY) @@ -90,6 +112,10 @@ internal class StakingViewModel @Inject constructor( private var userWallet: UserWallet by Delegates.notNull() private var appCurrency: AppCurrency by Delegates.notNull() + private var stakingApproval: StakingApproval = StakingApproval.Empty + + private var approvalJobHolder: JobHolder = JobHolder() + init { subscribeOnSelectedAppCurrency() subscribeOnBalanceHiding() @@ -100,55 +126,73 @@ internal class StakingViewModel @Inject constructor( stakingStateRouter.onBackClick() } - override fun onNextClick(pendingActions: ImmutableList) { - handleOnNextConfirmationClick() + override fun onNextClick(actionType: StakingActionCommonType?, pendingActions: ImmutableList) { + if (actionType != null) { + stateController.update { it.copy(actionType = actionType) } + } stakingStateRouter.onNextClick() if (isAssentState()) { - estimateGas(pendingActions) + getFee(pendingActions) } } - private fun handleOnNextConfirmationClick() { + override fun onActionClick(pendingAction: PendingAction?) { + handleOnNextConfirmationClick(pendingAction) + stakingStateRouter.onNextClick() + } + + private fun handleOnNextConfirmationClick(pendingAction: PendingAction?) { if (isAssentState()) { viewModelScope.launch { - stateController.update(SetConfirmationStateInProgressTransformer()) + stateController.update(SetConfirmationStateInProgressTransformer(pendingAction)) val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data ?: error("No confirmation state") val validatorState = confirmationState.validatorState as? ValidatorState.Content ?: error("No validator provided") - val pendingActions = confirmationState.pendingActions + val amountState = value.amountState as? AmountState.Data ?: error("No amount provided") + val amountValue = amountState.amountTextField.cryptoAmount.value ?: error("No amount value") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee ?: error("No fee provided") val stakingTransaction = getStakingTransactionUseCase( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, params = ActionParams( - actionCommonType = getStakingCommonType(), + actionCommonType = value.actionType, integrationId = yield.id, - amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value - ?: error("No amount provided"), + amount = amountValue, address = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value ?: error("No available address"), validatorAddress = validatorState.chosenValidator.address, token = yield.token, - passthrough = pendingActions.firstOrNull()?.passthrough, - type = pendingActions.firstOrNull()?.type, + passthrough = pendingAction?.passthrough, + type = pendingAction?.type, ), ).getOrElse { error(it) } - stakingTransaction.unsignedTransaction?.let { - sendStakingTransaction( - transactionId = stakingTransaction.id, - gasEstimate = stakingTransaction.gasEstimate ?: error("No gas estimate available"), - txData = TransactionData.Compiled(value = it.hexToBytes()), - pendingActions = pendingActions, - ) - } ?: error("No unsigned transaction available") + stakingTransaction + .filterNot { it.type == StakingTransactionType.APPROVAL } + .forEach { transaction -> + val (constructedTransaction, transactionData) = getConstructedStakingTransactionUseCase( + networkId = cryptoCurrencyStatus.currency.network.id.value, + fee = fee, + transactionId = transaction.id, + ).getOrNull() ?: error("No constructed transaction") + + sendStakingTransaction( + transactionId = constructedTransaction.id, + gasEstimate = constructedTransaction.gasEstimate ?: error("No gas estimate available"), + txData = transactionData, + pendingActionList = confirmationState.pendingActions, + ) + } } } } - private fun estimateGas(pendingActions: ImmutableList) { + private fun getFee(pendingActions: ImmutableList) { viewModelScope.launch { stateController.update( SetConfirmationStateLoadingTransformer( @@ -156,37 +200,112 @@ internal class StakingViewModel @Inject constructor( ), ) val cryptoCurrencyValue = cryptoCurrencyStatus.value + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val validatorState = confirmationState.validatorState as? ValidatorState.Content + ?: error("No validator provided") - val stakingGasEstimate = estimateGasUseCase( - params = ActionParams( - actionCommonType = getStakingCommonType(), - integrationId = yield.id, - amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value - ?: error("No amount provided"), - address = cryptoCurrencyValue.networkAddress?.defaultAddress?.value - ?: error("No available address"), - validatorAddress = yield.validators.getOrNull(0)?.address ?: error("No available validator"), - token = yield.token, - passthrough = pendingActions.firstOrNull()?.passthrough, - type = pendingActions.firstOrNull()?.type, - ), - ).getOrElse { error("Can't get fee info") } + val amount = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: error("No amount provided") + val sourceAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value + ?: error("No available address") + val validatorAddress = validatorState.chosenValidator.address - stateController.update( - SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - stakingGasEstimate = stakingGasEstimate, - pendingActionList = pendingActions, - ), - ) + val approval = stakingApproval as? StakingApproval.Needed + if (approval != null) { + val allowance = getAllowanceUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + spenderAddress = approval.spenderAddress, + ).getOrElse { BigDecimal.ZERO } + + if (allowance < amount) { + getApproveFee( + amount = amount, + validatorAddress = validatorAddress, + ) + } else { + estimateGas( + pendingActions = pendingActions, + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + ) + } + } else { + estimateGas( + pendingActions = pendingActions, + amount = amount, + sourceAddress = sourceAddress, + validatorAddress = validatorAddress, + ) + } } } + private suspend fun estimateGas( + pendingActions: ImmutableList, + amount: BigDecimal, + sourceAddress: String, + validatorAddress: String, + ) { + val pendingAction = pendingActions.firstOrNull() + val stakingGasEstimate = estimateGasUseCase( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, + params = ActionParams( + actionCommonType = value.actionType, + integrationId = yield.id, + amount = amount, + address = sourceAddress, + validatorAddress = validatorAddress, + token = yield.token, + passthrough = pendingAction?.passthrough, + type = pendingAction?.type, + ), + ).getOrElse { + stateController.update(AddStakingErrorTransformer(it)) + return + } + + stateController.update( + SetConfirmationStateAssentTransformer( + appCurrencyProvider = Provider { appCurrency }, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + stakingGasEstimate = stakingGasEstimate, + pendingActionList = pendingActions, + ), + ) + } + + private suspend fun getApproveFee(amount: BigDecimal, validatorAddress: String) { + val approvalFee = getFeeUseCase( + amount = amount, + destination = validatorAddress, + userWallet = userWallet, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).getOrElse { + // TODO staking error + return + } + + stateController.update( + SetConfirmationStateAssentApprovalTransformer( + appCurrencyProvider = Provider { appCurrency }, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + fee = approvalFee, + ), + ) + } + override fun onPrevClick() { stakingStateRouter.onPrevClick() } + override fun onInitialInfoBannerClick() { + // innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL) + } + override fun onInfoClick(infoType: InfoType) { stateController.update( ShowInfoBottomSheetStateTransformer(infoType) { @@ -196,7 +315,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 +323,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 +336,93 @@ internal class StakingViewModel @Inject constructor( stateController.update(ValidatorSelectChangeTransformer(validator)) } - override fun openRewardsValidators() { - stateController.update { it.copy(routeType = RouteType.CLAIM) } - onNextClick() - } - - override fun selectRewardValidator(rewardValue: String) { - stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, rewardValue)) - onNextClick() - } + override fun openRewardsValidators() = onNextClick(actionType = StakingActionCommonType.PENDING_REWARDS) override fun onActiveStake(activeStake: BalanceState) { - val routeType = if (activeStake.pendingActions.isEmpty()) { - RouteType.UNSTAKE + val actionType = if (activeStake.pendingActions.isEmpty()) { + StakingActionCommonType.EXIT } else { - RouteType.OTHER + StakingActionCommonType.PENDING_OTHER } - stateController.update { it.copy(routeType = routeType) } - stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, activeStake.cryptoValue)) - onNextClick(activeStake.pendingActions) + stateController.update(ValidatorSelectChangeTransformer(activeStake.validator)) + stateController.update(AmountChangeStateTransformer(cryptoCurrencyStatus, yield, activeStake.cryptoValue)) + onNextClick(actionType, activeStake.pendingActions) + } + + override fun showApprovalBottomSheet() { + stateController.update( + ShowApprovalBottomSheetTransformer( + appCurrencyProvider = Provider { appCurrency }, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + ) { + stateController.update(DismissBottomSheetStateTransformer()) + }, + ) + } + + override fun onApprovalClick() { + viewModelScope.launch { + stateController.update( + SetApprovalBottomSheetInProgressTransformer { + stateController.update(DismissBottomSheetStateTransformer()) + }, + ) + + val tokenCryptoCurrency = + cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: error("No token currency") + val amountValue = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + ?: error("No amount provided") + + val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data + ?: error("No confirmation state") + val fee = (confirmationState.feeState as? FeeState.Content)?.fee ?: error("No fee provided") + val approval = stakingApproval as? StakingApproval.Needed ?: error("No staking approve spender address") + + val approvalTransaction = createApprovalTransactionUseCase( + amount = amountValue, + contractAddress = tokenCryptoCurrency.contractAddress, + spenderAddress = approval.spenderAddress, + fee = fee, + cryptoCurrency = tokenCryptoCurrency, + userWalletId = userWalletId, + ).fold( + ifLeft = { error -> + Timber.e(error.toString()) + stateController.update( + SetConfirmationStateAssentApprovalTransformer( + appCurrencyProvider = Provider { appCurrency }, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + fee = TransactionFee.Single(fee), + ), + ) + // TODO staking error + return@launch + }, + ifRight = { it }, + ) + + sendTransactionUseCase( + txData = approvalTransaction, + userWallet = userWallet, + network = tokenCryptoCurrency.network, + ).fold( + ifLeft = { error -> + Timber.e(error.toString()) + stateController.update( + SetConfirmationStateAssentApprovalTransformer( + appCurrencyProvider = Provider { appCurrency }, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + fee = TransactionFee.Single(fee), + ), + ) + // TODO staking error + }, + ifRight = { + getFee(confirmationState.pendingActions) + stateController.update(DismissBottomSheetStateTransformer()) + }, + ) + }.saveIn(approvalJobHolder) } override fun onExploreClick() { @@ -249,7 +436,16 @@ internal class StakingViewModel @Inject constructor( } override fun onShareClick() { - // TODO staking analytics event + val confirmationDataState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data + val transactionDoneState = confirmationDataState?.transactionDoneState as? TransactionDoneState.Content + val txUrl = transactionDoneState?.txUrl + + if (txUrl != null) { + vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) + clipboardManager.setText(text = txUrl) + } + + // TODO staking [REDACTED_TASK_KEY] } fun setRouter(router: InnerStakingRouter, stateRouter: StakingStateRouter) { @@ -257,6 +453,10 @@ internal class StakingViewModel @Inject constructor( this.stakingStateRouter = stateRouter } + private fun setupApprovalNeeded() { + stakingApproval = isApproveNeededUseCase(cryptoCurrencyStatus.currency).getOrElse { StakingApproval.Empty } + } + private fun subscribeOnCurrencyStatusUpdates() { viewModelScope.launch { getUserWalletUseCase(userWalletId).fold( @@ -271,6 +471,8 @@ internal class StakingViewModel @Inject constructor( ifRight = { cryptoCurrencyStatus = it + setupApprovalNeeded() + val networkId = cryptoCurrencyStatus.currency.network.id val isStakeMoreAvailable = isStakeMoreAvailableUseCase(networkId) stateController.update( @@ -317,7 +519,7 @@ internal class StakingViewModel @Inject constructor( transactionId: String, gasEstimate: StakingGasEstimate, txData: TransactionData, - pendingActions: ImmutableList, + pendingActionList: ImmutableList, ) { sendTransactionUseCase( txData = txData, @@ -331,14 +533,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 +573,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/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index edd4767043..48724f9b77 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -7,7 +7,6 @@ import arrow.core.raise.either import arrow.core.right import com.squareup.moshi.Moshi import com.tangem.blockchain.common.* -import com.tangem.blockchain.extensions.Result import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.data.tokens.utils.CryptoCurrencyFactory import com.tangem.datasource.api.common.response.ApiResponse @@ -362,10 +361,10 @@ internal class DefaultSwapRepository @Inject constructor( ), ) ?: error("Cannot cast to Approver") - return when (result) { - is Result.Success -> result.data - is Result.Failure -> error(result.error) - } + return result.fold( + onSuccess = { it }, + onFailure = { error(it) }, + ) } override suspend fun getApproveData( diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 7cca09753d..9b44aacbef 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -4,7 +4,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync -import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.datasource.local.preferences.utils.getObjectMapSync import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId @@ -75,7 +75,7 @@ class DefaultSwapTransactionRepository( scanResponse: ScanResponse, ): Flow?> { return withContext(dispatchers.io) { - val txStatuses = appPreferencesStore.getObjectMap( + val txStatuses = appPreferencesStore.getObjectMapSync( key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, ) appPreferencesStore.getObjectList( diff --git a/features/swap/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..c0d6a4c835 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 @@ -268,6 +268,17 @@ internal class SwapInteractorImpl @Inject constructor( amountToSwap: String, selectedFee: FeeType, ): Map { + Timber.i( + """ + Find the best quote + |- fromToken: $fromToken + |- toToken: $toToken + |- providers: $providers + |- amountToSwap: $amountToSwap + |- selectedFee: $selectedFee + """.trimIndent(), + ) + return providers.map { provider -> val amountDecimal = toBigDecimalOrNull(amountToSwap) if (amountDecimal == null || amountDecimal.signum() == 0) { @@ -311,7 +322,7 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, ): Pair { - val quotes = repository.findBestQuote( + val maybeQuotes = repository.findBestQuote( fromContractAddress = fromToken.currency.getContractAddress(), fromNetwork = fromToken.currency.network.backendId, toContractAddress = toToken.currency.getContractAddress(), @@ -324,7 +335,7 @@ internal class SwapInteractorImpl @Inject constructor( ) val fromTokenAddress = getTokenAddress(fromToken.currency) - val isAllowedToSpend = quotes.fold( + val isAllowedToSpend = maybeQuotes.fold( ifRight = { quotes -> quotes.allowanceContract?.let { isAllowedToSpend(networkId, fromToken.currency, amount, it) @@ -352,7 +363,7 @@ internal class SwapInteractorImpl @Inject constructor( } else { provider to getQuotesState( provider = provider, - quoteDataModel = quotes, + quoteDataModel = maybeQuotes, amount = amount, fromToken = fromToken, toToken = toToken, @@ -573,6 +584,19 @@ internal class SwapInteractorImpl @Inject constructor( includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, ): SwapTransactionState { + Timber.i( + """ + Swap + |- swapProvider: $swapProvider + |- swapData: $swapData + |- currencyToSend: $currencyToSend + |- currencyToGet: $currencyToGet + |- amountToSwap: $amountToSwap + |- includeFeeInAmount: $includeFeeInAmount + |- fee: $fee + """.trimIndent(), + ) + val cardId = getSelectedWallet()?.scanResponse?.card?.cardId ?: return SwapTransactionState.UnknownError if (isDemoCardUseCase(cardId)) return SwapTransactionState.DemoMode @@ -888,6 +912,21 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = fee.gasLimit.toLong(), ) } + blockchain == Blockchain.Filecoin -> { + val gasUnitPrice = fee.feeValue.divide( + BigDecimal(fee.gasLimit), + Blockchain.Filecoin.decimals(), + RoundingMode.HALF_UP, + ) + Fee.Filecoin( + amount = feeAmount, + gasUnitPrice = gasUnitPrice + .movePointRight(Blockchain.Filecoin.decimals()) + .toLong(), + gasLimit = fee.gasLimit.toLong(), + gasPremium = requireNotNull(fee.gasPremium), + ) + } else -> Fee.Common(feeAmount) } } @@ -1579,6 +1618,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = minFee.fee.decimals, cryptoSymbol = minFee.fee.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (minFee as? ProxyFee.Filecoin)?.gasPremium, ), priorityFee = TxFee( feeValue = priorityFeeValue, @@ -1591,6 +1631,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = normalFee.fee.decimals, cryptoSymbol = normalFee.fee.currencySymbol, feeType = FeeType.PRIORITY, + gasPremium = (normalFee as? ProxyFee.Filecoin)?.gasPremium, ), ) } @@ -1633,6 +1674,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = singleFee.fee.decimals, cryptoSymbol = singleFee.fee.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (singleFee as? ProxyFee.Filecoin)?.gasPremium, ), ) } @@ -1688,6 +1730,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = normalFee.amount.decimals, cryptoSymbol = normalFee.amount.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (normalFee as? Fee.Filecoin)?.gasPremium, ), priorityFee = TxFee( feeValue = feePriority, @@ -1700,6 +1743,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = priorityFee.amount.decimals, cryptoSymbol = priorityFee.amount.currencySymbol, feeType = FeeType.PRIORITY, + gasPremium = (priorityFee as? Fee.Filecoin)?.gasPremium, ), ) } @@ -1731,6 +1775,7 @@ internal class SwapInteractorImpl @Inject constructor( decimals = normal.amount.decimals, cryptoSymbol = normal.amount.currencySymbol, feeType = FeeType.NORMAL, + gasPremium = (normal as? Fee.Filecoin)?.gasPremium, ), ) } @@ -1778,6 +1823,7 @@ internal class SwapInteractorImpl @Inject constructor( is Fee.Ethereum -> gasLimit.toInt() is Fee.VeChain -> gasLimit.toInt() is Fee.Aptos -> gasLimit.toInt() + is Fee.Filecoin -> gasLimit.toInt() else -> 0 } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt new file mode 100644 index 0000000000..4d066c82d2 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt @@ -0,0 +1,20 @@ +package com.tangem.feature.swap.preview + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.models.states.FeeItemState + +object FeeItemStatePreview { + + val state = FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "1000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(1000$)", + isClickable = false, + onClick = {}, + ) + + val stateClickable = state.copy(isClickable = true) +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt index e4c3b974ca..b3cb10ce22 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt @@ -1,20 +1,21 @@ package com.tangem.feature.swap.ui +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.SpacerH24 -import com.tangem.core.ui.components.rows.SimpleActionRow -import com.tangem.core.ui.extensions.resolveReference +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.swap.domain.models.ui.FeeType import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.preview.FeeItemStatePreview @Composable fun FeeItemBlock(state: FeeItemState) { @@ -25,53 +26,37 @@ fun FeeItemBlock(state: FeeItemState) { @Composable fun FeeItem(state: FeeItemState.Content) { - Box( + val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})" + val icon = R.drawable.ic_chevron_right_24.takeIf { state.isClickable } + InputRowDefault( + title = state.title, + text = stringReference(description), + iconRes = icon, modifier = Modifier - .background( - color = TangemTheme.colors.background.action, - shape = TangemTheme.shapes.roundedCornersXMedium, - ) .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .background(color = TangemTheme.colors.background.action) .clickable( + enabled = state.isClickable, onClick = state.onClick, - ) - .fillMaxWidth() - .defaultMinSize(minHeight = TangemTheme.dimens.size68), - ) { - val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})" - SimpleActionRow( - modifier = Modifier.padding( - start = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing12, ), - title = state.title.resolveReference(), - description = description, - isClickable = state.isClickable, - ) + ) +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun FeeItem_Preview(@PreviewParameter(FeeItemPreviewProvider::class) data: FeeItemState.Content) { + TangemThemePreview { + FeeItem(data) } } -@Preview -@Composable -private fun FeeItemPreview() { - val state = FeeItemState.Content( - feeType = FeeType.NORMAL, - title = stringReference("Fee"), - amountCrypto = "1000", - symbolCrypto = "MATIC", - amountFiatFormatted = "(1000$)", - isClickable = false, - onClick = {}, - ) - Column { - TangemThemePreview(isDark = false) { - FeeItem(state = state) - } - - SpacerH24() - - TangemThemePreview(isDark = true) { - FeeItem(state = state) - } - } -} \ No newline at end of file +private class FeeItemPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + FeeItemStatePreview.state, + FeeItemStatePreview.state.copy(isClickable = true), + ) +} +// endregion \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index 315c7a064a..9bf6689b1c 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -263,7 +263,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) { Column { Text( text = stringResource(R.string.express_provider), - style = TangemTheme.typography.caption2, + style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.secondary, modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), ) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index f02139cb42..eb128c84db 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -208,7 +208,7 @@ internal class SwapViewModel @Inject constructor( ) } }.onFailure { - Timber.tag(loggingTag).e(it) + Timber.e(it) applyInitialTokenChoice( state = TokensDataStateExpress.EMPTY, @@ -1212,7 +1212,6 @@ internal class SwapViewModel @Inject constructor( } private companion object { - const val loggingTag = "SwapViewModel" const val INITIAL_AMOUNT = "" const val UPDATE_DELAY = 10000L const val DEBOUNCE_AMOUNT_DELAY = 1000L diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 01480f6da6..0e94a8abeb 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { /** Compose */ implementation(deps.compose.material) + implementation(deps.compose.material3) implementation(deps.compose.foundation) implementation(deps.compose.navigation) implementation(deps.compose.navigation.hilt) @@ -33,9 +34,11 @@ dependencies { /** Other libraries */ implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) implementation(deps.timber) /** Core modules */ + implementation(projects.core.datasource) implementation(projects.core.featuretoggles) implementation(projects.core.ui) implementation(projects.core.utils) @@ -45,5 +48,6 @@ dependencies { implementation(projects.features.tester.api) /** Other modules */ + implementation(projects.common.routing) implementation(projects.libs.crypto) } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 62206ea5e5..10d761ffb9 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -1,18 +1,22 @@ package com.tangem.feature.tester.presentation import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.tangem.common.routing.AppRouter import com.tangem.core.navigation.finisher.AppFinisher import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeActivity import com.tangem.feature.tester.presentation.actions.TesterActionsScreen import com.tangem.feature.tester.presentation.actions.TesterActionsViewModel +import com.tangem.feature.tester.presentation.environments.ui.EnvironmentTogglesScreen +import com.tangem.feature.tester.presentation.environments.viewmodels.EnvironmentsTogglesViewModel import com.tangem.feature.tester.presentation.featuretoggles.ui.FeatureTogglesScreen import com.tangem.feature.tester.presentation.featuretoggles.viewmodels.FeatureTogglesViewModel import com.tangem.feature.tester.presentation.menu.state.TesterMenuContentState @@ -37,6 +41,9 @@ internal class TesterActivity : ComposeActivity() { @Inject lateinit var appFinisher: AppFinisher + @Inject + lateinit var appRouter: AppRouter + private val innerTesterRouter: InnerTesterRouter get() = requireNotNull(testerRouter as? InnerTesterRouter) { "TesterRouter must be InnerTesterRouter for tester feature" @@ -60,8 +67,9 @@ internal class TesterActivity : ComposeActivity() { composable(route = TesterScreen.MENU.name) { TesterMenuScreen( state = TesterMenuContentState( - onBackClick = innerTesterRouter::back, + onBackClick = { appRouter.pop { finish() } }, onFeatureTogglesClick = { innerTesterRouter.open(TesterScreen.FEATURE_TOGGLES) }, + onEnvironmentTogglesClick = { innerTesterRouter.open(TesterScreen.ENVIRONMENTS_TOGGLES) }, onTesterActionsClick = { innerTesterRouter.open(TesterScreen.TESTER_ACTIONS) }, ), ) @@ -75,6 +83,16 @@ internal class TesterActivity : ComposeActivity() { FeatureTogglesScreen(state = viewModel.uiState) } + composable(route = TesterScreen.ENVIRONMENTS_TOGGLES.name) { + val viewModel = hiltViewModel().apply { + setupNavigation(innerTesterRouter) + } + + EnvironmentTogglesScreen( + uiModel = viewModel.uiState.collectAsState().value, + ) + } + composable(route = TesterScreen.TESTER_ACTIONS.name) { val viewModel = hiltViewModel().apply { setupNavigation(innerTesterRouter) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/state/EnvironmentTogglesScreenUM.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/state/EnvironmentTogglesScreenUM.kt new file mode 100644 index 0000000000..44a644a9af --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/state/EnvironmentTogglesScreenUM.kt @@ -0,0 +1,35 @@ +package com.tangem.feature.tester.presentation.environments.state + +import androidx.annotation.StringRes +import kotlinx.collections.immutable.ImmutableSet + +/** + * Content state of environment toggles screen + * + * @property title title + * @property apiInfoList environment toggles list + * @property onEnvironmentSelect the lambda to be invoked when button is pressed + * @property onBackClick the lambda to be invoked when back button is pressed + */ +internal data class EnvironmentTogglesScreenUM( + @StringRes val title: Int, + val apiInfoList: ImmutableSet, + val onEnvironmentSelect: (id: String, environment: String) -> Unit, + val onBackClick: () -> Unit, +) { + + /** + * Api info + * + * @property name api name + * @property select select environment + * @property url select url + * @property environments list of environments + */ + data class ApiInfoUM( + val name: String, + val select: String, + val url: String, + val environments: ImmutableSet, + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt new file mode 100644 index 0000000000..b9c442ce86 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/ui/EnvironmentTogglesScreen.kt @@ -0,0 +1,169 @@ +package com.tangem.feature.tester.presentation.environments.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.feature.tester.impl.R +import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM +import kotlinx.collections.immutable.persistentSetOf + +/** + * Screen with environment toggles list + * + * @param uiModel screen state + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun EnvironmentTogglesScreen(uiModel: EnvironmentTogglesScreenUM) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary), + ) { + stickyHeader { + AppBarWithBackButton( + onBackClick = uiModel.onBackClick, + text = stringResource(id = uiModel.title), + ) + } + + itemsIndexed( + items = uiModel.apiInfoList.toTypedArray(), + key = { _, info -> info.name }, + ) { index, info -> + EnvironmentButtons( + uiModel = info, + onSelect = { isChange -> uiModel.onEnvironmentSelect(info.name, isChange) }, + modifier = Modifier.padding( + bottom = if (index == uiModel.apiInfoList.toTypedArray().lastIndex) { + TangemTheme.dimens.spacing0 + } else { + TangemTheme.dimens.spacing10 + }, + ), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EnvironmentButtons( + uiModel: EnvironmentTogglesScreenUM.ApiInfoUM, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = uiModel.name, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + style = TangemTheme.typography.h3, + ) + + AnimatedContent(targetState = uiModel.url, label = "") { + Text( + text = it, + color = TangemTheme.colors.text.accent, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = TangemTheme.typography.body2, + ) + } + } + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + uiModel.environments.onEachIndexed { index, environment -> + key(environment) { + SegmentedButton( + selected = environment == uiModel.select, + onClick = { onSelect(environment) }, + shape = when (index) { + 0 -> RoundedCornerShape( + topStart = TangemTheme.dimens.radius12, + bottomStart = TangemTheme.dimens.radius12, + ) + uiModel.environments.toTypedArray().lastIndex -> RoundedCornerShape( + topEnd = TangemTheme.dimens.radius12, + bottomEnd = TangemTheme.dimens.radius12, + ) + else -> RectangleShape + }, + colors = SegmentedButtonDefaults.colors( + activeContainerColor = TangemTheme.colors.control.checked, + activeContentColor = TangemTheme.colors.text.primary2, + inactiveContainerColor = TangemTheme.colors.control.unchecked, + inactiveContentColor = TangemTheme.colors.text.primary1, + ), + border = BorderStroke(0.dp, TangemTheme.colors.background.tertiary), + ) { + Text(text = environment, style = TangemTheme.typography.subtitle2) + } + } + } + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewFeatureTogglesScreen() { + TangemThemePreview { + var select by remember { mutableStateOf(ApiEnvironment.DEV.name) } + + EnvironmentTogglesScreen( + uiModel = EnvironmentTogglesScreenUM( + title = R.string.environment_toggles, + apiInfoList = persistentSetOf( + EnvironmentTogglesScreenUM.ApiInfoUM( + name = ApiConfig.ID.Express.name, + select = select, + url = "https://api.express.tangem.com", + environments = persistentSetOf( + ApiEnvironment.DEV.name, + ApiEnvironment.STAGE.name, + ApiEnvironment.PROD.name, + ), + ), + EnvironmentTogglesScreenUM.ApiInfoUM( + name = ApiConfig.ID.TangemTech.name, + select = select, + url = "https://api.express.tangem.com", + environments = persistentSetOf( + ApiEnvironment.DEV.name, + ApiEnvironment.PROD.name, + ), + ), + ), + onEnvironmentSelect = { _: String, s1: String -> select = s1 }, + onBackClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt new file mode 100644 index 0000000000..c8bf8c8b56 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt @@ -0,0 +1,96 @@ +package com.tangem.feature.tester.presentation.environments.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.datasource.api.common.config.managers.MutableApiConfigsManager +import com.tangem.feature.tester.impl.BuildConfig +import com.tangem.feature.tester.impl.R +import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM +import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModel for screen with list of environment toggles + * + * @param apiConfigsManager manager for getting information about the api configs + * +[REDACTED_AUTHOR] + */ +@HiltViewModel +internal class EnvironmentsTogglesViewModel @Inject constructor( + apiConfigsManager: ApiConfigsManager, +) : ViewModel() { + + /** Current ui state */ + val uiState: StateFlow + get() = _uiState + + private val _uiState = MutableStateFlow(value = getInitialState()) + + private val mutableApiConfigsManager: MutableApiConfigsManager = + requireNotNull(apiConfigsManager as? MutableApiConfigsManager) { + "MutableApiConfigsManager isn't available in build type ${BuildConfig.BUILD_TYPE}." + } + + init { + subscribeOnApiConfigs() + } + + /** Setup navigation state property by router [router] */ + fun setupNavigation(router: InnerTesterRouter) { + _uiState.update { + it.copy(onBackClick = router::back) + } + } + + private fun subscribeOnApiConfigs() { + mutableApiConfigsManager.configs + .onEach { configs -> + _uiState.update { + it.copy(apiInfoList = configs.toUiModel()) + } + } + .launchIn(viewModelScope) + } + + private fun Map.toUiModel(): ImmutableSet { + return map { + val (config, currentEnvironment) = it + + EnvironmentTogglesScreenUM.ApiInfoUM( + name = config.id.name, + select = currentEnvironment.name, + url = config.environmentConfigs.firstOrNull { it.environment == currentEnvironment }?.baseUrl + ?: error("Current environment's url isn't found"), + environments = config.environmentConfigs + .map { environmentConfig -> environmentConfig.environment.name } + .toImmutableSet(), + ) + } + .toImmutableSet() + } + + private fun getInitialState(): EnvironmentTogglesScreenUM { + return EnvironmentTogglesScreenUM( + title = R.string.environment_toggles, + apiInfoList = persistentSetOf(), + onEnvironmentSelect = ::onToggleValueChange, + onBackClick = {}, + ) + } + + private fun onToggleValueChange(id: String, name: String) { + viewModelScope.launch { + mutableApiConfigsManager.changeEnvironment(id = id, environment = ApiEnvironment.valueOf(name)) + } + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt index 736e01b6c6..c1e4e290bb 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/state/FeatureTogglesContentState.kt @@ -1,6 +1,7 @@ package com.tangem.feature.tester.presentation.featuretoggles.state import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle +import kotlinx.collections.immutable.ImmutableList /** * Content state of feature toggles screen @@ -11,7 +12,7 @@ import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatur * @property onApplyChangesClick the lambda to be invoked when apply changes button is pressed */ internal data class FeatureTogglesContentState( - val featureToggles: List, + val featureToggles: ImmutableList, val onToggleValueChange: (String, Boolean) -> Unit, val onBackClick: () -> Unit, val onApplyChangesClick: () -> Unit, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt index b43d13d2f3..0ebd8cadbf 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/ui/FeatureTogglesScreen.kt @@ -3,11 +3,7 @@ package com.tangem.feature.tester.presentation.featuretoggles.ui import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Switch @@ -26,6 +22,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.tester.impl.R import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState +import kotlinx.collections.immutable.persistentListOf /** * Screen with feature toggles list @@ -56,7 +53,8 @@ internal fun FeatureTogglesScreen(state: FeatureTogglesContentState) { PrimaryButton( text = stringResource(id = R.string.apply_changes), onClick = state.onApplyChangesClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(TangemTheme.dimens.spacing16), ) } @@ -98,7 +96,7 @@ private fun PreviewFeatureTogglesScreen() { TangemThemePreview { FeatureTogglesScreen( state = FeatureTogglesContentState( - featureToggles = listOf( + featureToggles = persistentListOf( TesterFeatureToggle(name = "FEATURE_TOGGLE_1", isEnabled = true), TesterFeatureToggle(name = "FEATURE_TOGGLE_2", isEnabled = false), ), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt index 2d810e1ebf..6200dd283a 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt @@ -13,6 +13,8 @@ import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureToggle import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import javax.inject.Inject @@ -64,9 +66,10 @@ internal class FeatureTogglesViewModel @Inject constructor( } } - private fun MutableFeatureTogglesManager.getTesterFeatureToggles(): List { + private fun MutableFeatureTogglesManager.getTesterFeatureToggles(): ImmutableList { return this .getFeatureToggles() .map { TesterFeatureToggle(it.key, it.value) } + .toImmutableList() } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt index a570b21660..a074e734b9 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/state/TesterMenuContentState.kt @@ -3,12 +3,14 @@ package com.tangem.feature.tester.presentation.menu.state /** * Content state of tester menu screen * - * @property onBackClick the lambda to be invoked when back button is pressed - * @property onFeatureTogglesClick the lambda to be invoked when feature toggles button is pressed - * @property onTesterActionsClick the lambda to be invoked when tester actions button is pressed + * @property onBackClick the lambda to be invoked when back button is pressed + * @property onFeatureTogglesClick the lambda to be invoked when feature toggles button is pressed + * @property onEnvironmentTogglesClick the lambda to be invoked when environment toggles button is pressed + * @property onTesterActionsClick the lambda to be invoked when tester actions button is pressed */ data class TesterMenuContentState( val onBackClick: () -> Unit, val onFeatureTogglesClick: () -> Unit, + val onEnvironmentTogglesClick: () -> Unit, val onTesterActionsClick: () -> Unit, ) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt index 39d9c5b95c..105ea332fd 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/menu/ui/TesterMenuScreen.kt @@ -1,12 +1,9 @@ package com.tangem.feature.tester.presentation.menu.ui import android.content.res.Configuration +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -27,6 +24,8 @@ import com.tangem.feature.tester.presentation.menu.state.TesterMenuContentState */ @Composable internal fun TesterMenuScreen(state: TesterMenuContentState) { + BackHandler(onBack = state.onBackClick) + Column( modifier = Modifier .fillMaxSize() @@ -36,6 +35,7 @@ internal fun TesterMenuScreen(state: TesterMenuContentState) { onBackClick = state.onBackClick, text = stringResource(id = R.string.tester_menu), ) + Column( modifier = Modifier .padding( @@ -50,12 +50,13 @@ internal fun TesterMenuScreen(state: TesterMenuContentState) { onClick = state.onFeatureTogglesClick, modifier = Modifier.fillMaxWidth(), ) + PrimaryButton( - text = stringResource(R.string.stand_toggles), - onClick = state.onFeatureTogglesClick, + text = stringResource(R.string.environment_toggles), + onClick = state.onEnvironmentTogglesClick, modifier = Modifier.fillMaxWidth(), - enabled = false, ) + PrimaryButton( modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.tester_actions), @@ -74,6 +75,7 @@ private fun PreviewTesterMenuScreen() { state = TesterMenuContentState( onBackClick = {}, onFeatureTogglesClick = {}, + onEnvironmentTogglesClick = {}, onTesterActionsClick = {}, ), ) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt index 903f287179..2d7622855b 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/navigation/TesterScreen.kt @@ -6,5 +6,5 @@ package com.tangem.feature.tester.presentation.navigation [REDACTED_AUTHOR] */ internal enum class TesterScreen { - MENU, FEATURE_TOGGLES, TESTER_ACTIONS + MENU, FEATURE_TOGGLES, ENVIRONMENTS_TOGGLES, TESTER_ACTIONS } \ No newline at end of file diff --git a/features/tester/impl/src/main/res/values/strings.xml b/features/tester/impl/src/main/res/values/strings.xml index ffc3114361..dfd6f4d39c 100644 --- a/features/tester/impl/src/main/res/values/strings.xml +++ b/features/tester/impl/src/main/res/values/strings.xml @@ -3,7 +3,7 @@ Tester menu Feature toggles Apply changes - Stand toggles + Environment toggles Tester actions Hide all currencies Toggle app theme - %s diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 182ed49b47..e7625b4013 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -129,7 +129,7 @@ internal object TokenDetailsPreviewData { onBalanceSelect = {}, displayCryptoBalance = "966,96 XLM", displayFiatBalance = "91,50$", - isBalanceSelectorEnabled = false, + isBalanceSelectorEnabled = true, ) val balanceError = TokenDetailsBalanceBlockState.Error( actionButtons = actionButtons, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt index f3104d8636..c5b6078527 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt @@ -91,19 +91,4 @@ internal sealed class TokenDetailsActionButton(val config: ActionButtonConfig) { dimContent = dimContent, ), ) - - /** - * Staking - * - * @property dimContent determines whether the button content will be dimmed - * @property onClick lambda be invoked when Swap button is clicked - */ - data class Stake(val dimContent: Boolean, override val onClick: () -> Unit) : TokenDetailsActionButton( - config = ActionButtonConfig( - text = TextReference.Res(id = R.string.common_stake), - iconResId = R.drawable.ic_staking_24, - onClick = onClick, - dimContent = dimContent, - ), - ) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt index afe2413ce6..dbf8a99f3c 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsActionButtonsConverter.kt @@ -38,12 +38,6 @@ internal class TokenDetailsActionButtonsConverter( onLongClick = clickIntents::onCopyAddress, ) } - is TokenActionsState.ActionState.Stake -> { - TokenDetailsActionButton.Stake( - dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, - onClick = { clickIntents.onStakeClick(action.unavailabilityReason) }, - ) - } is TokenActionsState.ActionState.Sell -> { TokenDetailsActionButton.Sell( dimContent = action.unavailabilityReason != ScenarioUnavailabilityReason.None, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index b5b0f8a5c6..6aa3923cd9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -11,10 +11,11 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.feature.tokendetails.presentation.tokendetails.state.BalanceType +import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState @@ -31,9 +32,11 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import java.math.BigDecimal +@Suppress("LongParameterList") internal class TokenDetailsLoadedBalanceConverter( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, + private val stakingEntryInfoProvider: Provider, private val symbol: String, private val decimals: Int, private val clickIntents: TokenDetailsClickIntents, @@ -136,39 +139,28 @@ internal class TokenDetailsLoadedBalanceConverter( private fun getYieldBalance(status: CryptoCurrencyStatus, state: TokenDetailsState): StakingBlockUM { val yieldBalance = status.value.yieldBalance as? YieldBalance.Data - val stakingCryptoAmount = yieldBalance?.getTotalStakingBalance() - val stakingRewardAmount = yieldBalance?.getRewardStakingBalance() - val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } - return if (stakingCryptoAmount.isNullOrZero()) { - StakingBlockUM.Loading(state.tokenInfoBlockState.iconState) - } else { - StakingBlockUM.Staked( - cryptoAmount = stakingCryptoAmount, - fiatAmount = stakingFiatAmount, - cryptoValue = stringReference( - BigDecimalFormatter.formatCryptoAmount(stakingCryptoAmount, symbol, decimals), - ), - fiatValue = stringReference( - BigDecimalFormatter.formatFiatAmount( - stakingFiatAmount, - appCurrencyProvider().code, - appCurrencyProvider().symbol, - ), - ), - rewardValue = resourceReference( - R.string.staking_details_rewards_to_claim, - wrappedList( - BigDecimalFormatter.formatFiatAmount( - stakingRewardAmount, - appCurrencyProvider().code, - appCurrencyProvider().symbol, - ), - ), - ), - onStakeClicked = clickIntents::onStakeBannerClick, - ) + val stakingEntryInfo = stakingEntryInfoProvider.invoke() + val iconState = state.tokenInfoBlockState.iconState + + return when { + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> { + getStakeAvailableState(stakingEntryInfo, iconState) + } + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo == null -> { + StakingBlockUM.Error(iconState = iconState) + } + else -> { + val stakingRewardAmount = yieldBalance?.getRewardStakingBalance() + val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } + + getStakedState( + stakingCryptoAmount = stakingCryptoAmount, + stakingFiatAmount = stakingFiatAmount, + stakingRewardAmount = stakingRewardAmount, + ) + } } } @@ -195,6 +187,54 @@ internal class TokenDetailsLoadedBalanceConverter( } } + private fun getStakeAvailableState( + stakingEntryInfo: StakingEntryInfo, + iconState: IconState, + ): StakingBlockUM.StakeAvailable { + return StakingBlockUM.StakeAvailable( + interestRate = BigDecimalFormatter.formatPercent( + percent = stakingEntryInfo.interestRate, + useAbsoluteValue = true, + ), + periodInDays = stakingEntryInfo.periodInDays, + tokenSymbol = stakingEntryInfo.tokenSymbol, + iconState = iconState, + onStakeClicked = clickIntents::onStakeBannerClick, + ) + } + + private fun getStakedState( + stakingCryptoAmount: BigDecimal?, + stakingFiatAmount: BigDecimal?, + stakingRewardAmount: BigDecimal?, + ): StakingBlockUM.Staked { + return StakingBlockUM.Staked( + cryptoAmount = stakingCryptoAmount, + fiatAmount = stakingFiatAmount, + cryptoValue = stringReference( + BigDecimalFormatter.formatCryptoAmount(stakingCryptoAmount, symbol, decimals), + ), + fiatValue = stringReference( + BigDecimalFormatter.formatFiatAmount( + stakingFiatAmount, + appCurrencyProvider().code, + appCurrencyProvider().symbol, + ), + ), + rewardValue = resourceReference( + R.string.staking_details_rewards_to_claim, + wrappedList( + BigDecimalFormatter.formatFiatAmount( + stakingRewardAmount, + appCurrencyProvider().code, + appCurrencyProvider().symbol, + ), + ), + ), + onStakeClicked = clickIntents::onStakeBannerClick, + ) + } + private fun CryptoCurrencyStatus.Value.toContentConfig(currencySymbol: String): MarketPriceBlockState.Content { return MarketPriceBlockState.Content( currencySymbol = currencySymbol, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index 3d6080faba..402e674bec 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.MutableStateFlow internal class TokenDetailsStateFactory( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, + private val stakingEntryInfoProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, private val clickIntents: TokenDetailsClickIntents, private val featureToggles: TokenDetailsFeatureToggles, @@ -79,6 +80,7 @@ internal class TokenDetailsStateFactory( TokenDetailsLoadedBalanceConverter( currentStateProvider = currentStateProvider, appCurrencyProvider = appCurrencyProvider, + stakingEntryInfoProvider = stakingEntryInfoProvider, symbol = symbol, decimals = decimals, clickIntents = clickIntents, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index d3e4bdff81..b814e9e554 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -147,17 +147,20 @@ private fun BalanceButtons(state: TokenDetailsBalanceBlockState) { .padding(top = TangemTheme.dimens.spacing11) .width(IntrinsicSize.Min), ) { config -> + val style = if (state.selectedBalanceType == config.type) { + TangemTheme.typography.caption1 + } else { + TangemTheme.typography.caption2 + } Text( text = config.title.resolveReference(), color = TangemTheme.colors.text.primary1, - style = TangemTheme.typography.caption1, + style = style, maxLines = 1, modifier = Modifier .padding( - start = TangemTheme.dimens.spacing5, - end = TangemTheme.dimens.spacing5, - top = TangemTheme.dimens.spacing3, - bottom = TangemTheme.dimens.spacing3, + horizontal = TangemTheme.dimens.spacing4, + vertical = TangemTheme.dimens.spacing6, ) .align(Alignment.Center), ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt index 0d5d72efd5..c1ce6e3abd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/StakingBalanceBlock.kt @@ -33,7 +33,7 @@ internal fun StakingBalanceBlock(state: StakingBlockUM.Staked, modifier: Modifie modifier = modifier .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action) + .background(TangemTheme.colors.background.primary) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/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/common/WalletPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt index e1f6a29f77..0ad7e674a7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt @@ -97,7 +97,7 @@ internal object WalletPreviewData { id = UUID.randomUUID().toString(), iconState = coinIconState, titleState = TokenItemState.TitleState.Content(text = "Polygon", hasPending = true), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $"), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $", hasStaked = true), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,412 MATIC"), cryptoPriceState = TokenItemState.CryptoPriceState.Unknown, onItemClick = {}, @@ -117,7 +117,7 @@ internal object WalletPreviewData { id = UUID.randomUUID().toString(), iconState = tokenIconState, titleState = TokenItemState.TitleState.Content(text = "Polygon"), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $"), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "321 $", hasStaked = false), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,412 MATIC"), cryptoPriceState = TokenItemState.CryptoPriceState.Content( price = "312 USD", diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt index 01c4e11d48..1917d4e4c8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt @@ -403,7 +403,10 @@ private class TokenItemStateProvider : CollectionPreviewParameterProvider { FiatAmountText( text = if (isBalanceHidden) StringsSigns.STARS else state.text, - modifier, + hasStaked = state.hasStaked, + modifier = modifier, ) } is TokenFiatAmountState.Loading -> { @@ -32,15 +41,31 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool } @Composable -private fun FiatAmountText(text: String, modifier: Modifier = Modifier) { - Text( - text = text, +private fun FiatAmountText(text: String, hasStaked: Boolean, modifier: Modifier = Modifier) { + Row( modifier = modifier, - color = TangemTheme.colors.text.primary1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TangemTheme.typography.body2, - ) + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility( + visible = hasStaked, + ) { + Icon( + painter = rememberVectorPainter(image = ImageVector.vectorResource(R.drawable.ic_staking_24)), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing4) + .size(TangemTheme.dimens.size12), + ) + } + Text( + text = text, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography.body2, + ) + } } private fun Modifier.placeholderSize(): Modifier = composed { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt index 6338c9f8b0..2ece6053fc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/state/TokenItemState.kt @@ -129,7 +129,10 @@ internal sealed class TokenItemState { @Immutable sealed class FiatAmountState { - data class Content(val text: String) : FiatAmountState() + data class Content( + val text: String, + val hasStaked: Boolean = false, + ) : FiatAmountState() object Loading : FiatAmountState() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/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/state/transformers/converter/TokenItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt index d7639404cc..9abebae38f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter +import com.tangem.common.extensions.isZero import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter @@ -55,6 +56,7 @@ internal class TokenItemStateConverter( ), fiatAmountState = TokenItemState.FiatAmountState.Content( text = getFormattedFiatAmount(), + hasStaked = !getStakedBalance().isZero(), ), cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = getFormattedAmount()), cryptoPriceState = getCryptoPriceState(), @@ -64,21 +66,22 @@ internal class TokenItemStateConverter( } private fun CryptoCurrencyStatus.getFormattedAmount(): String { - val yieldBalance = (value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() - val amount = value.amount?.plus(yieldBalance) ?: return DASH_SIGN + val amount = value.amount?.plus(getStakedBalance()) ?: return DASH_SIGN return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) } private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String { - val yieldBalance = (value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() - val fiatYieldBalance = value.fiatRate?.times(yieldBalance).orZero() + val fiatYieldBalance = value.fiatRate?.times(getStakedBalance()).orZero() val fiatAmount = value.fiatAmount?.plus(fiatYieldBalance) ?: return DASH_SIGN val appCurrency = appCurrencyProvider() return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol) } + private fun CryptoCurrencyStatus.getStakedBalance() = + (value.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance().orZero() + private fun CryptoCurrencyStatus.mapToUnreachableTokenItemState() = TokenItemState.Unreachable( id = currency.id.value, iconState = iconStateConverter.convert(value = this), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 806518dbe8..b8dd2da1b7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -49,6 +49,8 @@ import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TestTags @@ -70,12 +72,13 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balances import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.depositButton import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator import com.tangem.features.markets.component.BottomSheetState -import com.tangem.features.markets.component.MarketsListComponent +import com.tangem.features.markets.component.BottomSheetState.* +import com.tangem.features.markets.component.MarketsEntryComponent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @Composable -internal fun WalletScreen(state: WalletScreenState, marketsListComponent: MarketsListComponent?) { +internal fun WalletScreen(state: WalletScreenState, marketsEntryComponent: MarketsEntryComponent?) { BackHandler(onBack = state.onBackClick) // It means that screen is still initializing @@ -98,7 +101,7 @@ internal fun WalletScreen(state: WalletScreenState, marketsListComponent: Market snackbarHostState = snackbarHostState, isAutoScroll = isAutoScroll, onAutoScrollReset = { isAutoScroll.value = false }, - marketsListComponent = marketsListComponent, + marketsEntryComponent = marketsEntryComponent, alertConfig = alertConfig, ) @@ -119,7 +122,7 @@ private fun WalletContent( walletsListState: LazyListState, snackbarHostState: SnackbarHostState, isAutoScroll: State, - marketsListComponent: MarketsListComponent?, + marketsEntryComponent: MarketsEntryComponent?, alertConfig: WalletAlertState?, onAutoScrollReset: () -> Unit, ) { @@ -216,7 +219,7 @@ private fun WalletContent( ) } - if (marketsListComponent != null) { + if (marketsEntryComponent != null) { val bottomSheetState = remember { mutableStateOf(BottomSheetState.COLLAPSED) } @@ -232,7 +235,7 @@ private fun WalletContent( alertConfig = alertConfig, onBottomSheetStateChange = { bottomSheetState.value = it }, bottomSheetContent = { - marketsListComponent.BottomSheetContent( + marketsEntryComponent.BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = { headerSize = it }, modifier = Modifier, @@ -316,15 +319,15 @@ private fun BaseScaffold( @Suppress("LongParameterList", "LongMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable -private fun BaseScaffoldWithMarkets( +private inline fun BaseScaffoldWithMarkets( state: WalletScreenState, selectedWallet: WalletState, snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, - bottomSheetContent: @Composable () -> Unit, + crossinline bottomSheetContent: @Composable () -> Unit, alertConfig: WalletAlertState?, - onBottomSheetStateChange: (BottomSheetState) -> Unit, - content: @Composable () -> Unit, + noinline onBottomSheetStateChange: (BottomSheetState) -> Unit, + crossinline content: @Composable () -> Unit, ) { // show the bottom sheet if there is at least one multicurrency wallet val showManageTokensBottomSheet = remember(state.wallets) { @@ -332,11 +335,13 @@ private fun BaseScaffoldWithMarkets( } val bottomSheetState = rememberSheetStateEnhanced( initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden, - confirmValueChange = { sheetValue -> - when { - sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false - sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false - else -> true + confirmValueChange = remember(showManageTokensBottomSheet) { + { sheetValue -> + when { + sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false + sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false + else -> true + } } }, skipHiddenState = showManageTokensBottomSheet, @@ -344,14 +349,6 @@ private fun BaseScaffoldWithMarkets( val keyboardShown = keyboardAsState() - BottomSheetStateEffects( - bottomSheetState = bottomSheetState, - showManageTokensBottomSheet = showManageTokensBottomSheet, - alertConfig = alertConfig, - keyboardShown = keyboardShown, - onBottomSheetStateChange = onBottomSheetStateChange, - ) - val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = bottomSheetState, snackbarHostState = snackbarHostState, @@ -360,77 +357,89 @@ private fun BaseScaffoldWithMarkets( val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight + val maxHeight = LocalWindowSize.current.height val coroutineScope = rememberCoroutineScope() + val backgroundPrimary = TangemTheme.colors.background.primary - BottomSheetScaffold( - snackbarHost = { - WalletSnackbarHost( - snackbarHostState = it, - event = state.event, - modifier = Modifier - .padding(bottom = TangemTheme.dimens.spacing4) - .navigationBarsPadding(), - ) - }, - containerColor = TangemTheme.colors.background.secondary, - sheetContainerColor = TangemTheme.colors.background.primary, - scaffoldState = scaffoldState, - sheetPeekHeight = peekHeight, - sheetDragHandle = { - Hand(modifier = Modifier.background(color = TangemTheme.colors.background.primary)) - }, - sheetTonalElevation = 8.dp, - sheetShadowElevation = 8.dp, - sheetContent = { - BoxWithConstraints { - Box( + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(backgroundPrimary) }, + ) { + val backgroundColor = LocalMainBottomSheetColor.current + + BottomSheetStateEffects( + bottomSheetState = bottomSheetState, + showManageTokensBottomSheet = showManageTokensBottomSheet, + alertConfig = alertConfig, + keyboardShown = keyboardShown, + onBottomSheetStateChange = onBottomSheetStateChange, + ) + + BottomSheetScaffold( + snackbarHost = { + WalletSnackbarHost( + snackbarHostState = it, + event = state.event, modifier = Modifier - .sizeIn(maxHeight = maxHeight - statusBarHeight) - .align(Alignment.BottomCenter), + .padding(bottom = TangemTheme.dimens.spacing4) + .navigationBarsPadding(), + ) + }, + containerColor = TangemTheme.colors.background.secondary, + sheetContainerColor = backgroundColor.value, + scaffoldState = scaffoldState, + sheetPeekHeight = peekHeight, + sheetDragHandle = { + Hand(modifier = Modifier.background(color = backgroundColor.value)) + }, + sheetTonalElevation = 8.dp, + sheetShadowElevation = 8.dp, + sheetContent = { + Box( + modifier = Modifier.sizeIn(maxHeight = maxHeight - statusBarHeight - handComposableComponentHeight), ) { bottomSheetContent() } - } - // hide bottom sheet when back pressed - BackHandler( - keyboardShown.value is Keyboard.Closed && - bottomSheetState.currentValue == SheetValue.Expanded, - ) { - coroutineScope.launch { bottomSheetState.partialExpand() } - } - }, - content = { _ -> - val pullRefreshState = rememberPullRefreshState( - refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - onRefresh = { - selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) - }, - ) - - Column { - WalletTopBar(config = state.topBarConfig) - Box( - modifier = Modifier.pullRefresh(pullRefreshState), + // hide bottom sheet when back pressed + BackHandler( + keyboardShown.value is Keyboard.Closed && + bottomSheetState.currentValue == SheetValue.Expanded, ) { - content() - - WalletPullToRefreshIndicator( - isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) + coroutineScope.launch { bottomSheetState.partialExpand() } } - } + }, + content = { _ -> + val pullRefreshState = rememberPullRefreshState( + refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + onRefresh = { + selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) + }, + ) - BottomSheetScrim( - color = BottomSheetDefaults.ScrimColor, - visible = bottomSheetState.targetValue == SheetValue.Expanded, - onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, - ) - }, - ) + Column { + WalletTopBar(config = state.topBarConfig) + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + ) { + content() + + WalletPullToRefreshIndicator( + isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + + BottomSheetScrim( + color = BottomSheetDefaults.ScrimColor, + visible = bottomSheetState.targetValue == SheetValue.Expanded, + onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, + ) + }, + ) + } } @Composable @@ -545,9 +554,9 @@ private fun BottomSheetStateEffects( LaunchedEffect(isSheetHidden) { onBottomSheetStateChange( if (isSheetHidden) { - BottomSheetState.COLLAPSED + COLLAPSED } else { - BottomSheetState.EXPANDED + EXPANDED }, ) } @@ -640,7 +649,7 @@ private fun WalletScreen_Preview(@PreviewParameter(WalletScreenPreviewProvider:: TangemThemePreview { WalletScreen( state = data, - marketsListComponent = null, + marketsEntryComponent = null, ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt index 06bb5b53a9..b38bc57b61 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.viewmodels +import androidx.compose.runtime.Stable import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -43,6 +44,7 @@ import timber.log.Timber import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") +@Stable @HiltViewModel internal class WalletViewModel @Inject constructor( private val stateHolder: WalletStateController, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt index aeb7ea5c03..26d3d0c945 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolver.kt @@ -35,7 +35,7 @@ internal class WalletsUpdateActionResolver @Inject constructor( getUpdateContentAction(currentState, wallets, selectedWallet) } - Timber.d("Resolved action: $action") + Timber.i("Resolved action: $action") return action } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt index c6b5eb1cff..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..6201894fe4 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-734" #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/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt b/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt index 4388254787..8582fd21ed 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/ExpressAuthProvider.kt @@ -2,8 +2,6 @@ package com.tangem.lib.auth interface ExpressAuthProvider { - fun getApiKey(): String - fun getUserId(): String fun getSessionId(): String diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/sessionId/ExpressSessionIdGenerator.kt b/libs/auth/src/main/java/com/tangem/lib/auth/sessionId/ExpressSessionIdGenerator.kt deleted file mode 100644 index be7270b4c7..0000000000 --- a/libs/auth/src/main/java/com/tangem/lib/auth/sessionId/ExpressSessionIdGenerator.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.lib.auth.sessionId - -interface ExpressSessionIdGenerator { - - fun generateNewSessionId() -} \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt index 6207c86026..8fb67b9107 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt @@ -74,7 +74,7 @@ internal class DefaultBlockchainSDKFactory( return@launch } - Timber.d("Update BlockchainSDKConfig") + Timber.i("Update BlockchainSDKConfig") configStore.store( value = BlockchainSDKConfigConverter.convert(value = config), @@ -91,7 +91,7 @@ internal class DefaultBlockchainSDKFactory( return@launch } - Timber.d("Update BlockchainProviderTypes") + Timber.i("Update BlockchainProviderTypes") blockchainProviderTypesStore.store( value = BlockchainProviderTypesConverter.convert(response), diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 9ede47a471..e7224f8434 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -27,7 +27,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( ) { fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory { - Timber.d("Create WalletManagerFactory") + Timber.i("Create WalletManagerFactory") return WalletManagerFactory( config = config, diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt index f8e769ebd1..6209d90560 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/converters/BlockchainSDKConfigConverter.kt @@ -85,6 +85,8 @@ internal object BlockchainSDKConfigConverter : Converter Blockchain.KoinosTestnet "joystream" -> Blockchain.Joystream "bittensor" -> Blockchain.Bittensor + "filecoin" -> Blockchain.Filecoin + "blast" -> Blockchain.Blast + "blast/test" -> Blockchain.BlastTestnet + "cyber" -> Blockchain.Cyber + "cyber/test" -> Blockchain.CyberTestnet else -> null } } @@ -237,9 +242,17 @@ fun Blockchain.toNetworkId(): String { Blockchain.KoinosTestnet -> "koinos/test" Blockchain.Joystream -> "joystream" Blockchain.Bittensor -> "bittensor" + Blockchain.Filecoin -> "filecoin" + Blockchain.Blast -> "blast" + Blockchain.BlastTestnet -> "blast/test" + Blockchain.Cyber -> "cyber" + Blockchain.CyberTestnet -> "cyber/test" } } +/** + * CoinId is id from tangem backend response coin "id" field + */ @Suppress("ComplexMethod", "LongMethod") fun Blockchain.toCoinId(): String { return when (this) { @@ -313,6 +326,9 @@ fun Blockchain.toCoinId(): String { Blockchain.Koinos, Blockchain.KoinosTestnet -> "koinos" Blockchain.Joystream -> "joystream" Blockchain.Bittensor -> "bittensor" + Blockchain.Filecoin -> "filecoin" + Blockchain.Blast, Blockchain.BlastTestnet -> "blast" + Blockchain.Cyber, Blockchain.CyberTestnet -> "cyberconnect" } } @@ -325,7 +341,9 @@ fun Blockchain.amountToCreateAccount(token: Token? = null): BigDecimal? { Blockchain.Stellar -> if (token?.symbol == NODL) BigDecimal(NODL_AMOUNT_TO_CREATE_ACCOUNT) else BigDecimal.ONE Blockchain.XRP -> BigDecimal.TEN Blockchain.Near, Blockchain.NearTestnet -> 0.00182.toBigDecimal() - Blockchain.Aptos, Blockchain.AptosTestnet -> BigDecimal.ZERO + Blockchain.Aptos, Blockchain.AptosTestnet, + Blockchain.Filecoin, + -> BigDecimal.ZERO else -> null } } @@ -341,6 +359,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