Updated on 2026-08-14
This commit is contained in:
commit
04c1325ca0
120 changed files with 3503 additions and 316 deletions
|
|
@ -15,10 +15,12 @@ import com.tangem.features.markets.details.MarketsTokenDetailsComponent
|
|||
import com.tangem.features.nft.component.NFTCollectionsComponent
|
||||
import com.tangem.features.nft.component.NFTDetailsComponent
|
||||
import com.tangem.features.nft.component.NFTReceiveComponent
|
||||
import com.tangem.features.nft.component.NFTAssetTraitsComponent
|
||||
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
|
||||
import com.tangem.features.onramp.component.*
|
||||
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
|
||||
import com.tangem.features.send.api.SendComponent
|
||||
import com.tangem.features.send.v2.api.NFTSendComponent
|
||||
import com.tangem.features.send.v2.api.SendFeatureToggles
|
||||
import com.tangem.features.staking.api.StakingComponent
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
|
|
@ -86,6 +88,8 @@ internal class ChildFactory @Inject constructor(
|
|||
private val nftCollectionsComponentFactory: NFTCollectionsComponent.Factory,
|
||||
private val nftReceiveComponentFactory: NFTReceiveComponent.Factory,
|
||||
private val nftDetailsComponentFactory: NFTDetailsComponent.Factory,
|
||||
private val nftAssetTraitsComponentFactory: NFTAssetTraitsComponent.Factory,
|
||||
private val nftSendComponentFactory: NFTSendComponent.Factory,
|
||||
private val testerRouter: TesterRouter,
|
||||
private val routingFeatureToggles: RoutingFeatureToggles,
|
||||
private val walletConnectFeatureToggles: WalletConnectFeatureToggles,
|
||||
|
|
@ -420,9 +424,30 @@ internal class ChildFactory @Inject constructor(
|
|||
is AppRoute.NFTDetails ->
|
||||
createComponentChild(
|
||||
context = context,
|
||||
params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset),
|
||||
params = NFTDetailsComponent.Params(
|
||||
userWalletId = route.userWalletId,
|
||||
nftAsset = route.nftAsset,
|
||||
nftCollectionName = route.collectionName,
|
||||
),
|
||||
componentFactory = nftDetailsComponentFactory,
|
||||
)
|
||||
is AppRoute.NFTAssetTraits ->
|
||||
createComponentChild(
|
||||
context = context,
|
||||
params = NFTAssetTraitsComponent.Params(nftAsset = route.nftAsset),
|
||||
componentFactory = nftAssetTraitsComponentFactory,
|
||||
)
|
||||
is AppRoute.NFTSend -> {
|
||||
createComponentChild(
|
||||
context = context,
|
||||
params = NFTSendComponent.Params(
|
||||
userWalletId = route.userWalletId,
|
||||
nftAsset = route.nftAsset,
|
||||
nftCollectionName = route.nftCollectionName,
|
||||
),
|
||||
componentFactory = nftSendComponentFactory,
|
||||
)
|
||||
}
|
||||
is AppRoute.OnboardingNote,
|
||||
is AppRoute.SaveWallet,
|
||||
is AppRoute.OnboardingOther,
|
||||
|
|
@ -772,9 +797,30 @@ internal class ChildFactory @Inject constructor(
|
|||
is AppRoute.NFTDetails ->
|
||||
route.asComponentChild(
|
||||
contextProvider = contextProvider(route, contextFactory),
|
||||
params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset),
|
||||
params = NFTDetailsComponent.Params(
|
||||
userWalletId = route.userWalletId,
|
||||
nftAsset = route.nftAsset,
|
||||
nftCollectionName = route.collectionName,
|
||||
),
|
||||
componentFactory = nftDetailsComponentFactory,
|
||||
)
|
||||
is AppRoute.NFTAssetTraits ->
|
||||
route.asComponentChild(
|
||||
contextProvider = contextProvider(route, contextFactory),
|
||||
params = NFTAssetTraitsComponent.Params(nftAsset = route.nftAsset),
|
||||
componentFactory = nftAssetTraitsComponentFactory,
|
||||
)
|
||||
is AppRoute.NFTSend -> {
|
||||
route.asComponentChild(
|
||||
contextProvider = contextProvider(route, contextFactory),
|
||||
params = NFTSendComponent.Params(
|
||||
userWalletId = route.userWalletId,
|
||||
nftAsset = route.nftAsset,
|
||||
nftCollectionName = route.nftCollectionName,
|
||||
),
|
||||
componentFactory = nftSendComponentFactory,
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,5 +294,18 @@ sealed class AppRoute(val path: String) : Route {
|
|||
data class NFTDetails(
|
||||
val userWalletId: UserWalletId,
|
||||
val nftAsset: NFTAsset,
|
||||
val collectionName: String,
|
||||
) : AppRoute(path = "/nft_details/${userWalletId.stringValue}/${nftAsset.collectionId}/${nftAsset.id.stringValue}")
|
||||
|
||||
@Serializable
|
||||
data class NFTAssetTraits(
|
||||
val nftAsset: NFTAsset,
|
||||
) : AppRoute(path = "/nft_traits/${nftAsset.collectionId}/${nftAsset.id.stringValue}")
|
||||
|
||||
@Serializable
|
||||
data class NFTSend(
|
||||
val userWalletId: UserWalletId,
|
||||
val nftAsset: NFTAsset,
|
||||
val nftCollectionName: String,
|
||||
) : AppRoute(path = "/send/nft/${userWalletId.stringValue}/$nftCollectionName/${nftAsset.id}")
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.data.common)
|
||||
|
||||
implementation(projects.domain.legacy)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.common.test.data.quote
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
object MockQuoteResponseFactory {
|
||||
|
||||
fun createSinglePrice(value: BigDecimal): QuotesResponse.Quote {
|
||||
return QuotesResponse.Quote(
|
||||
price = value,
|
||||
priceChange24h = value,
|
||||
priceChange1w = value,
|
||||
priceChange30d = value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.common.test.data.quote
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.local.quote.converter.QuoteConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
|
||||
fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): Quote {
|
||||
return QuoteConverter(source = source).convert(value = mapOf(rawCurrencyId to this).entries.first())
|
||||
}
|
||||
|
||||
fun Pair<String, QuotesResponse.Quote>.toDomain(source: StatusSource = StatusSource.ACTUAL): Quote {
|
||||
return QuoteConverter(source = source).convert(value = mapOf(this).entries.first())
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.datasource.appcurrency
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||
|
||||
/**
|
||||
* Store of app currency data model [CurrenciesResponse.Currency]
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface AppCurrencyResponseStore {
|
||||
|
||||
suspend fun getSyncOrNull(): CurrenciesResponse.Currency?
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.datasource.appcurrency
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||
|
||||
/**
|
||||
* Default implementation of [AppCurrencyResponseStore]
|
||||
*
|
||||
* @property appPreferencesStore app preferences store
|
||||
*/
|
||||
internal class DefaultAppCurrencyResponseStore(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : AppCurrencyResponseStore {
|
||||
|
||||
override suspend fun getSyncOrNull(): CurrenciesResponse.Currency? {
|
||||
return appPreferencesStore.getObjectSyncOrNull<CurrenciesResponse.Currency>(
|
||||
PreferencesKeys.SELECTED_APP_CURRENCY_KEY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
package com.tangem.datasource.di
|
||||
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.datasource.appcurrency.DefaultAppCurrencyResponseStore
|
||||
import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore
|
||||
import com.tangem.datasource.local.appcurrency.implementation.DefaultAvailableAppCurrenciesStore
|
||||
import com.tangem.datasource.local.datastore.RuntimeDataStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -18,4 +21,10 @@ internal object AppCurrencyDataModule {
|
|||
fun provideAvailableAppCurrenciesStore(): AvailableAppCurrenciesStore {
|
||||
return DefaultAvailableAppCurrenciesStore(dataStore = RuntimeDataStore())
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppCurrencyResponseStore(appPreferencesStore: AppPreferencesStore): AppCurrencyResponseStore {
|
||||
return DefaultAppCurrencyResponseStore(appPreferencesStore)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,12 +51,14 @@ class MoshiModule {
|
|||
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
|
||||
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")
|
||||
.withSubtype(NFTCollection.Identifier.TON::class.java, "ton")
|
||||
.withSubtype(NFTCollection.Identifier.Solana::class.java, "sol")
|
||||
.withDefaultValue(NFTCollection.Identifier.Unknown),
|
||||
)
|
||||
.add(
|
||||
PolymorphicJsonAdapterFactory.of(NFTAsset.Identifier::class.java, "bc")
|
||||
.withSubtype(NFTAsset.Identifier.EVM::class.java, "evm")
|
||||
.withSubtype(NFTAsset.Identifier.TON::class.java, "ton")
|
||||
.withSubtype(NFTAsset.Identifier.Solana::class.java, "sol")
|
||||
.withDefaultValue(NFTAsset.Identifier.Unknown),
|
||||
)
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter<SdkNFTAsset.Identifier,
|
|||
is SdkNFTAsset.Identifier.TON -> NFTAsset.Identifier.TON(
|
||||
tokenAddress = value.tokenAddress,
|
||||
)
|
||||
is SdkNFTAsset.Identifier.Solana -> NFTAsset.Identifier.Solana(
|
||||
tokenAddress = value.tokenAddress,
|
||||
cnft = value.cnft,
|
||||
)
|
||||
is SdkNFTAsset.Identifier.Unknown -> NFTAsset.Identifier.Unknown
|
||||
}
|
||||
|
||||
|
|
@ -24,6 +28,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter<SdkNFTAsset.Identifier,
|
|||
is NFTAsset.Identifier.TON -> SdkNFTAsset.Identifier.TON(
|
||||
tokenAddress = value.tokenAddress,
|
||||
)
|
||||
is NFTAsset.Identifier.Solana -> SdkNFTAsset.Identifier.Solana(
|
||||
tokenAddress = value.tokenAddress,
|
||||
cnft = value.cnft,
|
||||
)
|
||||
is NFTAsset.Identifier.Unknown -> SdkNFTAsset.Identifier.Unknown
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter<SdkNFTCollection.Id
|
|||
is SdkNFTCollection.Identifier.TON -> NFTCollection.Identifier.TON(
|
||||
contractAddress = value.contractAddress,
|
||||
)
|
||||
is SdkNFTCollection.Identifier.Solana -> NFTCollection.Identifier.Solana(
|
||||
collection = value.collection,
|
||||
)
|
||||
is SdkNFTCollection.Identifier.Unknown -> NFTCollection.Identifier.Unknown
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +25,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter<SdkNFTCollection.Id
|
|||
is NFTCollection.Identifier.TON -> SdkNFTCollection.Identifier.TON(
|
||||
contractAddress = value.contractAddress,
|
||||
)
|
||||
is NFTCollection.Identifier.Solana -> SdkNFTCollection.Identifier.Solana(
|
||||
collection = value.collection,
|
||||
)
|
||||
is NFTCollection.Identifier.Unknown -> SdkNFTCollection.Identifier.Unknown
|
||||
}
|
||||
}
|
||||
|
|
@ -10,13 +10,24 @@ import com.tangem.utils.extensions.orZero
|
|||
/**
|
||||
* Converter from [QuotesResponse.Quote] to [Quote.Value]
|
||||
*
|
||||
* @property isCached flag that determines whether the quote is a cache
|
||||
* @property source status source
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuoteConverter(private val isCached: Boolean) :
|
||||
class QuoteConverter(
|
||||
private val source: StatusSource,
|
||||
) :
|
||||
Converter<Map.Entry<String, QuotesResponse.Quote>, Quote.Value> {
|
||||
|
||||
/**
|
||||
* Secondary constructor
|
||||
*
|
||||
* @param isCached flag that determines whether the quote is a cache
|
||||
*/
|
||||
constructor(isCached: Boolean) : this(
|
||||
source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL,
|
||||
)
|
||||
|
||||
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): Quote.Value {
|
||||
val (currencyId, quote) = value
|
||||
|
||||
|
|
@ -24,7 +35,7 @@ internal class QuoteConverter(private val isCached: Boolean) :
|
|||
rawCurrencyId = CryptoCurrency.RawID(currencyId),
|
||||
fiatRate = quote.price.orZero(),
|
||||
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
|
||||
source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -503,6 +503,12 @@
|
|||
<item quantity="one">%d Stück</item>
|
||||
<item quantity="other">%d Stücke</item>
|
||||
</plurals>
|
||||
<string name="nft_collections_empty_description">NFTs, die an Deine Wallet-Adresse gesendet werden, werden hier angezeigt.</string>
|
||||
<string name="nft_collections_empty_title">Noch keine Kollektionen</string>
|
||||
<string name="nft_collections_receive">NFT erhalten</string>
|
||||
<string name="nft_collections_title">NFT-Kollektionen</string>
|
||||
<string name="nft_collections_warning_subtitle">Einige Daten werden möglicherweise nicht geladen</string>
|
||||
<string name="nft_collections_warning_title">Vorübergehende Ladeprobleme</string>
|
||||
<string name="nft_wallet_count">%1$d NFTs in der %2$d Sammlung</string>
|
||||
<string name="nft_wallet_receive_nft">Tippe hier, um das erste NFT zu erhalten</string>
|
||||
<string name="nft_wallet_title">NFT-Sammlungen</string>
|
||||
|
|
@ -522,7 +528,7 @@
|
|||
<string name="onboarding_activation_error_message">Bitte wiederhole den Vorgang. Die Karte oder Ring wird auf Werkseinstellungen zurückgesetzt.</string>
|
||||
<string name="onboarding_activation_error_title">Aktivierungsfehler</string>
|
||||
<string name="onboarding_add_tokens">Token hinzufügen</string>
|
||||
<string name="onboarding_alert_message_not_max_backup_cards_added">Du hast einee Backup-Karte oder einen Backup-Ring hinzugefügt. Wenn der Backup-Prozess abgeschlossen ist, kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du noch eine Karte oder einen Ring hast, fügen diese(n) zum Backup hinzu. Möchtest Du den Backup-Prozess fortsetzen?</string>
|
||||
<string name="onboarding_alert_message_not_max_backup_cards_added">Du hast eine Backup-Karte oder einen Backup-Ring hinzugefügt. Nach Abschluss des Backup-Vorgangs kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du eine weitere Karte oder einen weiteren Ring hast, fügen diesen dem Backup jetzt hinzu. Möchtest Du den Backup-Vorgang fortsetzen?</string>
|
||||
<string name="onboarding_backup_exit_warning">Der Sicherungsvorgang ist teilweise abgeschlossen. Du kannst ihn jetzt nicht beenden.</string>
|
||||
<string name="onboarding_bottom_sheet_passphrase_description">Die Passphrase ist eine fortschrittliche Sicherheitsfunktion, die von Krypto-Wallets verwendet wird. Sie fügt ein zusätzliches Wort oder eine Phrase deiner Wahl zu der bereits bestehenden Wiederherstellungsphrase hinzu, um einen brandneuen Satz von Adressen zu erzeugen.</string>
|
||||
<string name="onboarding_button_add_backup_card">Hinzufügen einer Sicherungskarte oder Ring</string>
|
||||
|
|
@ -1165,6 +1171,7 @@
|
|||
<string name="warning_token_balance_not_updated">Der Kontostand ist möglicherweise veraltet. Aktualisiere bittre die Seite.</string>
|
||||
<string name="wc_connections">Verbindungen</string>
|
||||
<string name="wc_disconnect_all">Alle trennen</string>
|
||||
<string name="wc_disconnect_all_alert_desc">Text über die Trennung aller dApps</string>
|
||||
<string name="wc_disconnect_all_alert_title">Alle dApps trennen</string>
|
||||
<string name="wc_new_connection">Neue Verbindung</string>
|
||||
<string name="wc_no_sessions_desc">Verbinde Deine Wallet mit einer anderen dApp</string>
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@
|
|||
<string name="common_fee_selector_option_market">Marché</string>
|
||||
<string name="common_fee_selector_option_slow">Lent</string>
|
||||
<string name="common_fee_selector_title">Vitesse et frais</string>
|
||||
<string name="common_finish">Terminer</string>
|
||||
<string name="common_generate_addresses">Synchroniser les adresses</string>
|
||||
<string name="common_go_to_provider">Aller au fournisseur</string>
|
||||
<string name="common_go_to_token">Aller au jeton</string>
|
||||
|
|
@ -159,6 +160,7 @@
|
|||
<string name="common_no_address">Aucune adresse</string>
|
||||
<string name="common_now">Maintenant</string>
|
||||
<string name="common_ok">OK</string>
|
||||
<string name="common_open_in_browser">Ouvrir dans le navigateur</string>
|
||||
<string name="common_origin_card">Carte principale</string>
|
||||
<string name="common_origin_ring">Bague principale</string>
|
||||
<string name="common_passphrase">Passphrase</string>
|
||||
|
|
@ -182,6 +184,7 @@
|
|||
<string name="common_send">Envoyer</string>
|
||||
<string name="common_server_unavailable">Le serveur n\'est pas disponible, veuillez réessayer plus tard</string>
|
||||
<string name="common_share">Partager</string>
|
||||
<string name="common_share_link">Partager le lien</string>
|
||||
<string name="common_sign">Signez</string>
|
||||
<string name="common_sign_and_send">Signez et envoyez</string>
|
||||
<string name="common_stake">Stake</string>
|
||||
|
|
@ -496,6 +499,16 @@
|
|||
<string name="markets_tooltip_message">Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché</string>
|
||||
<string name="markets_tooltip_title">Ajouter des jetons</string>
|
||||
<string name="nfc_error_unavailable">NFC n\'est pas disponible sur votre appareil</string>
|
||||
<string name="nft_collections_empty_description">Les NFT envoyés à l\'adresse de votre portefeuille s\'afficheront ici.</string>
|
||||
<string name="nft_collections_empty_title">Aucune collection pour le moment</string>
|
||||
<string name="nft_collections_receive">Recevoir des NFT</string>
|
||||
<string name="nft_collections_title">Collections NFT</string>
|
||||
<string name="nft_collections_warning_subtitle">Certaines données peuvent ne pas se charger</string>
|
||||
<string name="nft_collections_warning_title">Problèmes de chargement temporaires</string>
|
||||
<string name="nft_wallet_count">%1$d NFT dans la collection %2$d</string>
|
||||
<string name="nft_wallet_receive_nft">Appuyez ici pour recevoir le premier NFT</string>
|
||||
<string name="nft_wallet_title">Collections NFT</string>
|
||||
<string name="nft_wallet_unable_to_load">Impossible de charger les données</string>
|
||||
<string name="onboarding_access_code_feature_1_description">Vous devez définir un seul code d\'accès pour protéger tous vos appareils.</string>
|
||||
<string name="onboarding_access_code_feature_1_title">Protéger</string>
|
||||
<string name="onboarding_access_code_feature_2_description">Vous pourrez définir un code d\'accès individuel sur chaque carte plus tard</string>
|
||||
|
|
@ -762,6 +775,8 @@
|
|||
<string name="send_summary_transaction_description_suffix_including">y compris des frais de réseau de %1$s</string>
|
||||
<string name="send_transaction_success">La transaction a été signée avec succès et envoyée au nœud de blockchain. Le solde du portefeuille sera mis à jour après un certain temps</string>
|
||||
<string name="send_tron_account_activation_error">%1$s est un actif du réseau Tron. Pour calculer les frais et effectuer une transaction, déposez du Tron (TRX) sur votre compte.</string>
|
||||
<string name="send_validation_destination_tag_required_description">Une balise de destination (mémo) est requise pour terminer cette transaction pour l\'adresse spécifiée.</string>
|
||||
<string name="send_validation_destination_tag_required_title">Étiquette de destination requise</string>
|
||||
<string name="sent_transaction_sent_title">Transaction envoyée</string>
|
||||
<string name="settings_card_settings_footer">Scannez la carte/ bague que vous souhaitez configurer.</string>
|
||||
<string name="settings_forget_wallet">Oublier le portefeuille</string>
|
||||
|
|
@ -783,6 +798,7 @@
|
|||
<string name="staking_details_estimated_profit">%s profit estimatif</string>
|
||||
<string name="staking_details_market_rating">Cote du marché</string>
|
||||
<string name="staking_details_metrics_block_header">Métriques</string>
|
||||
<string name="staking_details_min_rewards_notification">Selon les règles du réseau %1$s, les réclamations sont possibles à partir de %2$s. Les montants ci-dessous seront crédités sur votre compte lors du déblocage.</string>
|
||||
<string name="staking_details_minimum_requirement">Minimum requis</string>
|
||||
<string name="staking_details_no_rewards_to_claim">Aucune récompense à réclamer</string>
|
||||
<string name="staking_details_reward_claiming">Réclamation de récompense</string>
|
||||
|
|
@ -820,11 +836,16 @@
|
|||
<string name="staking_notification_low_staked_balance_title">Solde de staking faible</string>
|
||||
<string name="staking_notification_minimum_balance_error_text">Un minimum de %1$s %2$s est requis pour le re-staking. Veuillez recharger votre solde.</string>
|
||||
<string name="staking_notification_minimum_balance_error_title">Pas assez de %s</string>
|
||||
<string name="staking_notification_minimum_balance_title">Solde insuffisant pour le staking</string>
|
||||
<string name="staking_notification_minimum_restake_ada_text">Un minimum de 3 ADA est requis pour le re-staking. Veuillez recharger votre solde.</string>
|
||||
<string name="staking_notification_minimum_restake_ada_title">ADA insuffisants</string>
|
||||
<string name="staking_notification_minimum_stake_ada_text">Le montant minimum requis pour le staking doit être supérieur à 5 ADA. Veuillez recharger votre solde pour commencer à staking.</string>
|
||||
<string name="staking_notification_network_error_text">L\'option de staking n\'est actuellement pas disponible en raison des conditions du réseau. Veuillez réessayer plus tard.</string>
|
||||
<string name="staking_notification_new_validator_funds_transfer">Le staking dans le réseau %1$s avec un nouveau validateur transférera automatiquement tous les fonds précédemment stakés vers ce validateur</string>
|
||||
<string name="staking_notification_restake_rewards_text">Réinvestissez vos récompenses gagnées dans le montant que vous avez staké, augmentant ainsi vos gains potentiels.</string>
|
||||
<string name="staking_notification_restake_text">L\'option de restaker vous permet de déplacer vos fonds d\'un validateur à un autre sans avoir besoin de les déstaker.</string>
|
||||
<string name="staking_notification_stake_entire_balance_text">Vous êtes sur le point de staker l\'intégralité de votre solde. Nous vous recommandons de laisser un petit montant pour couvrir les frais de réseau pour unstaking ou la réclamation des récompenses.</string>
|
||||
<string name="staking_notification_ton_activate_account">Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille.</string>
|
||||
<string name="staking_notification_unlock_text">Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s.</string>
|
||||
<string name="staking_notification_unstake_cosmos_text">Vos fonds seront disponibles à l\'utilisation après la période de déblocage de 21 jours. La récompense sera retirée en même temps que vos fonds de déblocage.</string>
|
||||
<string name="staking_notification_unstake_text">Vos fonds seront disponibles pour utilisation après la période de désengagement %s.</string>
|
||||
|
|
@ -853,6 +874,7 @@
|
|||
<string name="staking_rewards">Récompenses</string>
|
||||
<string name="staking_stake_locked">Stake verrouillé</string>
|
||||
<string name="staking_stake_more">Staker plus</string>
|
||||
<string name="staking_stake_more_button_unavailability_reason">Lorsque vous stakez %1$s, la totalité de votre solde %2$s est stakeée. Tout dépôt supplémentaire de %2$s sur votre portefeuille Tangem sera également staké automatiquement.</string>
|
||||
<string name="staking_staked_amount">Montant staké</string>
|
||||
<string name="staking_summary_description_text">Vous stakez %1$s et recevrez %2$s</string>
|
||||
<string name="staking_tap_to_unlock">Appuyez pour déverrouiller</string>
|
||||
|
|
@ -887,6 +909,8 @@
|
|||
<string name="story_meet_title">Découvrez Tangem</string>
|
||||
<string name="story_web3_description">Échangez, achetez des NFT, faites des prêts et des dépôts dans plus de 100 services décentralisés différents</string>
|
||||
<string name="story_web3_title">Compatible avec Web 3.0</string>
|
||||
<string name="sui_not_enough_coin_for_fee_description">Une transaction entrante d\'au moins de %1$s est requise pour continuer</string>
|
||||
<string name="sui_not_enough_coin_for_fee_title">Fonds insuffisants</string>
|
||||
<string name="swap_give_permission_fee_footer">Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange.</string>
|
||||
<string name="swap_promo_text">Échangez plus de jetons à de meilleurs taux directement dans votre portefeuille.</string>
|
||||
<string name="swap_promo_title">Nouveau fournisseur d\'échange disponible !</string>
|
||||
|
|
@ -926,6 +950,7 @@
|
|||
<string name="token_button_unavailability_reason_empty_balance_send">Vous n\'avez pas de fonds à envoyer. Renflouez votre compte pour pouvoir envoyer des fonds à partir de celui-ci.</string>
|
||||
<string name="token_button_unavailability_reason_loading">Die Daten wurden noch nicht geladen. Dies kann einige Sekunden dauern. Bitte versuchen Sie es später noch einmal.</string>
|
||||
<string name="token_button_unavailability_reason_not_exchangeable">Le service d\'échange %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options.</string>
|
||||
<string name="token_button_unavailability_reason_out_of_date_balance">Le solde affiché peut être obsolète en raison de la mise en cache.</string>
|
||||
<string name="token_button_unavailability_reason_pending_transaction_sell">La vente de fonds sera disponible une fois que la ou les transactions en attente dans le réseau %s seront terminées</string>
|
||||
<string name="token_button_unavailability_reason_pending_transaction_send">L\'envoi de fonds sera disponible une fois la ou les transactions en attente dans le réseau %s terminées.</string>
|
||||
<string name="token_button_unavailability_reason_sell_unavailable">L\'achat de %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options.</string>
|
||||
|
|
@ -972,6 +997,7 @@
|
|||
<string name="twins_recreate_toolbar">Tangem Twin</string>
|
||||
<string name="twins_recreate_warning">Cette action est irréversible. Vous n\'aurez plus accès à l\'ancien portefeuille.</string>
|
||||
<string name="twins_scan_twin_with_number">Appuyez sur la carte jumelle avec le numéro %s et ne la retirez pas jusqu\'à la fin de l\'opération</string>
|
||||
<string name="universal_error">Nous avons rencontré une erreur. Code d\'erreur : %s. Veuillez contacter notre équipe de support.</string>
|
||||
<string name="unlock_wallet_description_full">Utilisez %s ou scannez une carte/bague pour avoir accès à votre portefeuille</string>
|
||||
<string name="unsupported_wc_version">Échec de la connexion : Cette dApp utilise Wallet Connect version1.0, qui n\'est pas prise en charge. Veuillez vous assurer que la dApp prend en charge Wallet Connect version2.0 pour réussir la connexion.</string>
|
||||
<string name="user_push_notification_agreement_argument_one">Restez à jour avec les dernières fonctionnalités et actualités</string>
|
||||
|
|
@ -986,6 +1012,24 @@
|
|||
<string name="user_wallet_list_rename_popup_title">Renommer le portefeuille</string>
|
||||
<string name="user_wallet_list_unlock_all">Tout déverrouiller</string>
|
||||
<string name="user_wallet_list_unlock_all_with">Tout déverrouiller avec %s</string>
|
||||
<plurals name="visa_limits_available_for_days_title">
|
||||
<item quantity="one">disponible pour %d jour</item>
|
||||
<item quantity="other">disponible pour %d jours</item>
|
||||
</plurals>
|
||||
<string name="visa_main_balances_and_limits">Soldes et Limites</string>
|
||||
<string name="visa_onboarding_close_alert_message">Êtes-vous sûr de vouloir quitter ? Vous pourrez reprendre plus tard là où vous vous étiez arrêté.</string>
|
||||
<string name="visa_onboarding_in_progress_description">Cela ne prendra pas longtemps. Nous configurons votre compte.</string>
|
||||
<string name="visa_onboarding_in_progress_issuer_description">Cela ne prendra pas longtemps. Nous terminons l\'activation.</string>
|
||||
<string name="visa_onboarding_in_progress_title">Tout est en cours de préparation !</string>
|
||||
<string name="visa_onboarding_pin_validation_error_message">Code PIN invalide : évitez les séquences ou les répétitions</string>
|
||||
<string name="visa_onboarding_wallet_connect_title">Accéder le site Web</string>
|
||||
<string name="visa_onboarding_welcome_back_description">Continuons la configuration de votre compte.</string>
|
||||
<string name="visa_onboarding_welcome_back_title">Content de vous revoir !</string>
|
||||
<string name="visa_onboarding_welcome_description">Suivez les étapes pour configurer votre compte.</string>
|
||||
<string name="visa_onboarding_welcome_title">Bienvenue !</string>
|
||||
<string name="visa_unlock_notification_button">Déverrouiller</string>
|
||||
<string name="visa_unlock_notification_subtitle">Scannez votre carte pour déverrouiller l\'accès</string>
|
||||
<string name="visa_unlock_notification_title">Déverrouillage nécessaire</string>
|
||||
<string name="wallet_balance_blockchain_unreachable_try_later">La blockchain n\'est pas accessible. Réessayez plus tard</string>
|
||||
<string name="wallet_balance_missing_derivation">Scanner la carte ou la bague</string>
|
||||
<string name="wallet_been_activated_message">Ce portefeuille a déjà été activé auparavant.\nSi cela n\'a pas été fait par vous, veuillez contacter le support.\nTangem ne vend jamais de portefeuilles avec le code d\'accès pré-généré.</string>
|
||||
|
|
@ -1116,6 +1160,13 @@
|
|||
<string name="warning_testnet_card_message">Il s\'agit d\'une carte Testnet. Elle ne peut pas traiter les transactions et ne doit être utilisée qu\'à des fins de test et de développement.</string>
|
||||
<string name="warning_testnet_card_title">À des fins de test uniquement</string>
|
||||
<string name="warning_token_balance_not_updated">Le solde peut être obsolète. Rafraîchissez la page.</string>
|
||||
<string name="wc_connections">Connexions</string>
|
||||
<string name="wc_disconnect_all">Déconnecter tout</string>
|
||||
<string name="wc_disconnect_all_alert_desc">Texte sur la déconnexion de toutes les dApps</string>
|
||||
<string name="wc_disconnect_all_alert_title">Déconnecter toutes les dApps</string>
|
||||
<string name="wc_new_connection">Nouvelle connexion</string>
|
||||
<string name="wc_no_sessions_desc">Connectez votre portefeuille à différentes dApps</string>
|
||||
<string name="wc_no_sessions_title">Aucune séance</string>
|
||||
<string name="welcome_interrupted_backup_alert_discard">Ignorer</string>
|
||||
<string name="welcome_interrupted_backup_alert_message">Vous avez une sauvegarde interrompue. Voulez-vous la reprendre ?</string>
|
||||
<string name="welcome_interrupted_backup_alert_resume">Oui, reprendre</string>
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@
|
|||
<string name="common_network_fee_title">ネットワーク手数料</string>
|
||||
<string name="common_network_fee_warning_content">送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。</string>
|
||||
<string name="common_next">次</string>
|
||||
<string name="common_nft">NFT</string>
|
||||
<string name="common_no">いいえ</string>
|
||||
<string name="common_no_address">アドレスがありません</string>
|
||||
<string name="common_now">今</string>
|
||||
|
|
@ -175,6 +176,7 @@
|
|||
<string name="common_search">検索</string>
|
||||
<string name="common_search_tokens">トークンを検索</string>
|
||||
<string name="common_second_no_param">秒</string>
|
||||
<string name="common_see_all">すべて見る</string>
|
||||
<string name="common_seed_phrase">シードフレーズ</string>
|
||||
<string name="common_select_action">アクションを選択</string>
|
||||
<string name="common_sell">売る</string>
|
||||
|
|
@ -502,6 +504,19 @@
|
|||
<string name="nft_collections_title">NFTコレクション</string>
|
||||
<string name="nft_collections_warning_subtitle">一部のデータが読み込まれない場合があります</string>
|
||||
<string name="nft_collections_warning_title">一時的な読み込みの問題</string>
|
||||
<string name="nft_details_base_information">基本情報</string>
|
||||
<string name="nft_details_chain">チェーン</string>
|
||||
<string name="nft_details_contract_address">コントラクトアドレス</string>
|
||||
<string name="nft_details_last_sale_price">最終販売価格</string>
|
||||
<string name="nft_details_rarity_label">レアリティ・ラベル</string>
|
||||
<string name="nft_details_rarity_rank">レアリティ・ランク</string>
|
||||
<string name="nft_details_token_address">トークンアドレス</string>
|
||||
<string name="nft_details_token_id">トークンID</string>
|
||||
<string name="nft_details_token_standard">トークン標準</string>
|
||||
<string name="nft_details_traits">特徴</string>
|
||||
<string name="nft_empty_search">結果がありません。別のリクエストをお試しください。</string>
|
||||
<string name="nft_receive_subtitle">私のウォレットへ</string>
|
||||
<string name="nft_receive_title">NFTを受け取る</string>
|
||||
<string name="nft_wallet_count">%1$dコレクションの%2$dNFT</string>
|
||||
<string name="nft_wallet_receive_nft">ここをタップして最初のNFTを受け取ります</string>
|
||||
<string name="nft_wallet_title">NFTコレクション</string>
|
||||
|
|
@ -635,6 +650,7 @@
|
|||
<string name="qr_scanner_camera_denied_title">カメラへのアクセスが拒否されました</string>
|
||||
<string name="receive_bottom_sheet_no_memo_required_message">メモ不要</string>
|
||||
<string name="receive_bottom_sheet_warning_message">%3$sネットワーク上の%1$s ( %2$s )</string>
|
||||
<string name="receive_bottom_sheet_warning_message_compact">%2$sネットワーク上の%1$s</string>
|
||||
<string name="receive_bottom_sheet_warning_message_description">他の暗号資産を送信すると、取り返しのつかない損失が発生します。</string>
|
||||
<string name="receive_bottom_sheet_warning_message_full">このアドレスには%sのみを送金してください。他のトークンを送信すると、取り返しのつかない損失が発生します。</string>
|
||||
<string name="receive_bottom_sheet_warning_title">%2$sネットワークの%1$sのみを送信してください</string>
|
||||
|
|
|
|||
|
|
@ -110,13 +110,14 @@
|
|||
<string name="common_claim_rewards">Claim rewards</string>
|
||||
<string name="common_close">Close</string>
|
||||
<string name="common_confirm">Confirm</string>
|
||||
<string name="common_contact_tangem_support">Contact Tangem Support</string>
|
||||
<string name="common_contact_visa_support">Contact Visa Support</string>
|
||||
<string name="common_continue">Continue</string>
|
||||
<string name="common_copy">Copy</string>
|
||||
<string name="common_copy_address">Copy address</string>
|
||||
<string name="common_create">Create</string>
|
||||
<string name="common_crypto_fiat_format">%1$s (%2$s)</string>
|
||||
<string name="common_custom">Custom</string>
|
||||
<string name="common_nft">NFT</string>
|
||||
<plurals name="common_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
|
|
@ -157,6 +158,7 @@
|
|||
<string name="common_network_fee_title">Network fee</string>
|
||||
<string name="common_network_fee_warning_content">Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level</string>
|
||||
<string name="common_next">Next</string>
|
||||
<string name="common_nft">NFT</string>
|
||||
<string name="common_no">No</string>
|
||||
<string name="common_no_address">No address</string>
|
||||
<string name="common_now">Now</string>
|
||||
|
|
@ -512,24 +514,25 @@
|
|||
<string name="nft_collections_title">NFT collections</string>
|
||||
<string name="nft_collections_warning_subtitle">Some data may not load</string>
|
||||
<string name="nft_collections_warning_title">Temporary loading problems</string>
|
||||
<string name="nft_details_base_information">Base information</string>
|
||||
<string name="nft_details_chain">Chain</string>
|
||||
<string name="nft_details_contract_address">Contract Address</string>
|
||||
<string name="nft_details_last_sale_price">Last sale price</string>
|
||||
<string name="nft_details_rarity_label">Rarity label</string>
|
||||
<string name="nft_details_rarity_rank">Rarity rank</string>
|
||||
<string name="nft_details_token_address">Token Address</string>
|
||||
<string name="nft_details_token_id">Token ID</string>
|
||||
<string name="nft_details_token_standard">Token Standard</string>
|
||||
<string name="nft_details_traits">Traits</string>
|
||||
<string name="nft_empty_search">No results. Please try another request.</string>
|
||||
<string name="nft_receive_title">Receive NFT</string>
|
||||
<string name="nft_receive_choose_network">Choose network</string>
|
||||
<string name="nft_receive_subtitle">To My wallet</string>
|
||||
<string name="nft_receive_title">Receive NFT</string>
|
||||
<string name="nft_wallet_count">%1$d NFTs in %2$d collection</string>
|
||||
<string name="nft_wallet_receive_nft">Tap here to receive first NFT</string>
|
||||
<string name="nft_wallet_title">NFT collections</string>
|
||||
<string name="nft_wallet_unable_to_load">Unable to load the data</string>
|
||||
<string name="nft_receive_choose_network">Choose network</string>
|
||||
<string name="nft_details_last_sale_price">Last sale price</string>
|
||||
<string name="nft_details_rarity_label">Rarity label</string>
|
||||
<string name="nft_details_rarity_rank">Rarity rank</string>
|
||||
<string name="nft_details_traits">Traits</string>
|
||||
<string name="nft_details_base_information">Base information</string>
|
||||
<string name="nft_details_token_standard">Token Standard</string>
|
||||
<string name="nft_details_contract_address">Contract Address</string>
|
||||
<string name="nft_details_token_id">Token ID</string>
|
||||
<string name="nft_details_token_address">Token Address</string>
|
||||
<string name="nft_details_chain">Chain</string>
|
||||
<string name="nft_traits_title">Traits</string>
|
||||
<string name="onboarding_access_code_feature_1_description">Set up a single access code to protect all your devices.</string>
|
||||
<string name="onboarding_access_code_feature_1_title">Protect</string>
|
||||
<string name="onboarding_access_code_feature_2_description">Set an individual access code for each card or ring later.</string>
|
||||
|
|
@ -1098,6 +1101,7 @@
|
|||
<string name="visa_transaction_details_transaction_request">Transaction request</string>
|
||||
<string name="visa_transaction_details_transaction_status">Transaction status</string>
|
||||
<string name="visa_transaction_details_type">Type</string>
|
||||
<string name="visa_tx_dispute_button">Dispute this transaction</string>
|
||||
<string name="visa_unlock_notification_button">Unlock</string>
|
||||
<string name="visa_unlock_notification_subtitle">Scan your card to unlock access</string>
|
||||
<string name="visa_unlock_notification_title">Needed unlock</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,485 @@
|
|||
package com.tangem.core.ui.components.atoms.text
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
**/
|
||||
|
||||
private const val READ_MORE_TAG = "read_more"
|
||||
private const val READ_LESS_TAG = "read_less"
|
||||
|
||||
/**
|
||||
* Basic element that displays text with read more.
|
||||
*
|
||||
* @param text The text to be displayed.
|
||||
* @param expanded whether this text is expanded or collapsed.
|
||||
* @param modifier [Modifier] to apply to this layout node.
|
||||
* @param onExpandRequested called when this text is clicked. If `null`, then this text will not be
|
||||
* interactable, unless something else handles its input events and updates its state.
|
||||
* @param contentPadding a padding around the text.
|
||||
* @param style Style configuration for the text such as color, font, line height etc.
|
||||
* @param onTextLayout Callback that is executed when a new text layout is calculated. A
|
||||
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
|
||||
* text, baselines and other details. The callback can be used to add additional decoration or
|
||||
* functionality to the text. For example, to draw selection around the text.
|
||||
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
|
||||
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
|
||||
* [readMoreOverflow] and TextAlign may have unexpected effects.
|
||||
* @param readMoreText The read more text to be displayed in the collapsed state.
|
||||
* @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if
|
||||
* necessary. If the text exceeds the given number of lines, it will be truncated according to
|
||||
* [readMoreOverflow]. If it is not null, then it must be greater than zero.
|
||||
* @param readMoreOverflow How visual overflow should be handled in the collapsed state.
|
||||
* @param readMoreStyle Style configuration for the read more text such as color, font, line height
|
||||
* etc.
|
||||
* @param readLessText The read less text to be displayed in the expanded state.
|
||||
* @param readLessStyle Style configuration for the read less text such as color, font, line height
|
||||
* etc.
|
||||
* @param toggleArea A clickable area of text to toggle.
|
||||
*/
|
||||
@Composable
|
||||
fun ReadMoreText(
|
||||
text: String,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onExpandRequested: ((Boolean) -> Unit)? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
style: TextStyle = TextStyle.Default,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
softWrap: Boolean = true,
|
||||
readMoreText: String = "",
|
||||
readMoreMaxLines: Int = 2,
|
||||
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
|
||||
readMoreStyle: SpanStyle = style.toSpanStyle(),
|
||||
readLessText: String = "",
|
||||
readLessStyle: SpanStyle = readMoreStyle,
|
||||
toggleArea: ToggleArea = ToggleArea.All,
|
||||
) {
|
||||
ReadMoreTextInternal(
|
||||
text = AnnotatedString(text),
|
||||
expanded = expanded,
|
||||
modifier = modifier,
|
||||
onExpandRequested = onExpandRequested,
|
||||
contentPadding = contentPadding,
|
||||
style = style,
|
||||
onTextLayout = onTextLayout,
|
||||
softWrap = softWrap,
|
||||
readMoreText = readMoreText,
|
||||
readMoreMaxLines = readMoreMaxLines,
|
||||
readMoreOverflow = readMoreOverflow,
|
||||
readMoreStyle = readMoreStyle,
|
||||
readLessText = readLessText,
|
||||
readLessStyle = readLessStyle,
|
||||
toggleArea = toggleArea,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic element that displays text with read more.
|
||||
*
|
||||
* @param text The text to be displayed.
|
||||
* @param expanded whether this text is expanded or collapsed.
|
||||
* @param modifier [Modifier] to apply to this layout node.
|
||||
* @param onExpandRequested called when this text is clicked. If `null`, then this text will not be
|
||||
* interactable, unless something else handles its input events and updates its state.
|
||||
* @param contentPadding a padding around the text.
|
||||
* @param style Style configuration for the text such as color, font, line height etc.
|
||||
* @param onTextLayout Callback that is executed when a new text layout is calculated. A
|
||||
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
|
||||
* text, baselines and other details. The callback can be used to add additional decoration or
|
||||
* functionality to the text. For example, to draw selection around the text.
|
||||
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
|
||||
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
|
||||
* [readMoreOverflow] and TextAlign may have unexpected effects.
|
||||
* @param readMoreText The read more text to be displayed in the collapsed state.
|
||||
* @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if
|
||||
* necessary. If the text exceeds the given number of lines, it will be truncated according to
|
||||
* [readMoreOverflow]. If it is not null, then it must be greater than zero.
|
||||
* @param readMoreOverflow How visual overflow should be handled in the collapsed state.
|
||||
* @param readMoreStyle Style configuration for the read more text such as color, font, line height
|
||||
* etc.
|
||||
* @param readLessText The read less text to be displayed in the expanded state.
|
||||
* @param readLessStyle Style configuration for the read less text such as color, font, line height
|
||||
* etc.
|
||||
* @param toggleArea A clickable area of text to toggle.
|
||||
*/
|
||||
@Composable
|
||||
fun ReadMoreText(
|
||||
text: AnnotatedString,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onExpandRequested: ((Boolean) -> Unit)? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
style: TextStyle = TextStyle.Default,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
softWrap: Boolean = true,
|
||||
readMoreText: String = "",
|
||||
readMoreMaxLines: Int = 2,
|
||||
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
|
||||
readMoreStyle: SpanStyle = style.toSpanStyle(),
|
||||
readLessText: String = "",
|
||||
readLessStyle: SpanStyle = readMoreStyle,
|
||||
toggleArea: ToggleArea = ToggleArea.All,
|
||||
) {
|
||||
ReadMoreTextInternal(
|
||||
text = text,
|
||||
expanded = expanded,
|
||||
modifier = modifier,
|
||||
onExpandRequested = onExpandRequested,
|
||||
contentPadding = contentPadding,
|
||||
style = style,
|
||||
onTextLayout = onTextLayout,
|
||||
softWrap = softWrap,
|
||||
readMoreText = readMoreText,
|
||||
readMoreMaxLines = readMoreMaxLines,
|
||||
readMoreOverflow = readMoreOverflow,
|
||||
readMoreStyle = readMoreStyle,
|
||||
readLessText = readLessText,
|
||||
readLessStyle = readLessStyle,
|
||||
toggleArea = toggleArea,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "LongParameterList")
|
||||
@Composable
|
||||
private fun ReadMoreTextInternal(
|
||||
text: AnnotatedString,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onExpandRequested: ((Boolean) -> Unit)? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
style: TextStyle = TextStyle.Default,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
softWrap: Boolean = true,
|
||||
readMoreText: String = "",
|
||||
readMoreMaxLines: Int = 2,
|
||||
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
|
||||
readMoreStyle: SpanStyle = style.toSpanStyle(),
|
||||
readLessText: String = "",
|
||||
readLessStyle: SpanStyle = readMoreStyle,
|
||||
toggleArea: ToggleArea = ToggleArea.All,
|
||||
) {
|
||||
require(readMoreMaxLines > 0) { "readMoreMaxLines should be greater than 0" }
|
||||
|
||||
val overflowText: String = remember(readMoreOverflow) {
|
||||
buildString {
|
||||
when (readMoreOverflow) {
|
||||
ReadMoreTextOverflow.Clip -> {
|
||||
}
|
||||
ReadMoreTextOverflow.Ellipsis -> {
|
||||
append(Typography.ellipsis)
|
||||
}
|
||||
}
|
||||
if (readMoreText.isNotEmpty()) {
|
||||
append(Typography.nbsp)
|
||||
}
|
||||
}
|
||||
}
|
||||
val readMoreTextWithStyle: AnnotatedString = remember(readMoreText, readMoreStyle) {
|
||||
buildAnnotatedString {
|
||||
if (readMoreText.isNotEmpty()) {
|
||||
withStyle(readMoreStyle) {
|
||||
append(readMoreText.replace(' ', Typography.nbsp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val readLessTextWithStyle: AnnotatedString = remember(readLessText, readLessStyle) {
|
||||
buildAnnotatedString {
|
||||
if (readLessText.isNotEmpty()) {
|
||||
withStyle(readLessStyle) {
|
||||
append(readLessText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val state = remember { ReadMoreState() }
|
||||
|
||||
val currentText = buildAnnotatedString {
|
||||
if (expanded) {
|
||||
append(text)
|
||||
if (readLessTextWithStyle.isNotEmpty()) {
|
||||
append(' ')
|
||||
if (toggleArea == ToggleArea.More) {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(tag = READ_LESS_TAG) {
|
||||
onExpandRequested?.invoke(false)
|
||||
},
|
||||
) {
|
||||
append(readLessTextWithStyle)
|
||||
}
|
||||
} else {
|
||||
append(readLessTextWithStyle)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val collapsedText = state.collapsedText
|
||||
if (collapsedText.isNotEmpty()) {
|
||||
append(collapsedText)
|
||||
append(overflowText)
|
||||
|
||||
if (toggleArea == ToggleArea.More) {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(tag = READ_MORE_TAG) {
|
||||
onExpandRequested?.invoke(true)
|
||||
},
|
||||
) {
|
||||
append(readMoreTextWithStyle)
|
||||
}
|
||||
} else {
|
||||
append(readMoreTextWithStyle)
|
||||
}
|
||||
} else {
|
||||
append(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
val toggleableModifier = if (onExpandRequested != null && toggleArea == ToggleArea.All) {
|
||||
Modifier.clickable(
|
||||
enabled = state.isCollapsible,
|
||||
onClick = { onExpandRequested(!expanded) },
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.then(toggleableModifier)
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
BasicText(
|
||||
text = currentText,
|
||||
modifier = Modifier,
|
||||
style = style,
|
||||
onTextLayout = onTextLayout,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
softWrap = softWrap,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines,
|
||||
)
|
||||
|
||||
val constraints = Constraints(maxWidth = constraints.maxWidth)
|
||||
LaunchedEffect(
|
||||
textMeasurer,
|
||||
constraints,
|
||||
overflowText,
|
||||
readMoreTextWithStyle,
|
||||
style,
|
||||
readMoreStyle,
|
||||
text,
|
||||
readMoreMaxLines,
|
||||
softWrap,
|
||||
) {
|
||||
state.applyCollapsedText(
|
||||
textMeasurer = textMeasurer,
|
||||
constraints = constraints,
|
||||
overflowText = overflowText,
|
||||
readMoreTextWithStyle = readMoreTextWithStyle,
|
||||
style = style,
|
||||
readMoreStyle = readMoreStyle,
|
||||
text = text,
|
||||
readMoreMaxLines = readMoreMaxLines,
|
||||
softWrap = softWrap,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
private class ReadMoreState {
|
||||
private var _collapsedText: AnnotatedString by mutableStateOf(AnnotatedString(""))
|
||||
|
||||
var collapsedText: AnnotatedString
|
||||
get() = _collapsedText
|
||||
internal set(value) {
|
||||
if (value != _collapsedText) {
|
||||
_collapsedText = value
|
||||
}
|
||||
}
|
||||
|
||||
val isCollapsible: Boolean
|
||||
get() = collapsedText.isNotEmpty()
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun applyCollapsedText(
|
||||
textMeasurer: TextMeasurer,
|
||||
constraints: Constraints,
|
||||
overflowText: String,
|
||||
readMoreTextWithStyle: AnnotatedString,
|
||||
style: TextStyle,
|
||||
readMoreStyle: SpanStyle,
|
||||
text: AnnotatedString,
|
||||
readMoreMaxLines: Int,
|
||||
softWrap: Boolean,
|
||||
) {
|
||||
val overflowTextWidth = if (overflowText.isNotEmpty()) {
|
||||
textMeasurer.measure(
|
||||
text = overflowText,
|
||||
style = style,
|
||||
).size.width
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val readMoreTextWidth = if (readMoreTextWithStyle.isNotEmpty()) {
|
||||
textMeasurer.measure(
|
||||
text = readMoreTextWithStyle,
|
||||
style = style.merge(readMoreStyle),
|
||||
).size.width
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val textLayout = textMeasurer.measure(
|
||||
text = text,
|
||||
style = style,
|
||||
maxLines = readMoreMaxLines,
|
||||
overflow = TextOverflow.Clip,
|
||||
softWrap = softWrap,
|
||||
constraints = constraints,
|
||||
)
|
||||
|
||||
val clipTextCount = textLayout.getLineEnd(lineIndex = textLayout.lineCount - 1)
|
||||
val isLineClipped = text.count() > clipTextCount
|
||||
if (isLineClipped) {
|
||||
val countUntilMaxLine =
|
||||
textLayout.getLineEnd(readMoreMaxLines - 1, visibleEnd = true)
|
||||
|
||||
val decorationWidth = overflowTextWidth + readMoreTextWidth
|
||||
val replaceCount = text
|
||||
.substringOf(textLayout, line = readMoreMaxLines)
|
||||
.calculateReplaceCountToBeSingleLineWith(
|
||||
maximumTextWidth = constraints.maxWidth - decorationWidth,
|
||||
measureTextWidth = { subText ->
|
||||
textMeasurer.measure(
|
||||
text = subText,
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
).size.width
|
||||
},
|
||||
)
|
||||
collapsedText = text.subSequence(0, countUntilMaxLine - replaceCount)
|
||||
} else {
|
||||
collapsedText = AnnotatedString("")
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.substringOf(layout: TextLayoutResult, line: Int): AnnotatedString {
|
||||
val lastLineStartIndex = layout.getLineStart(line - 1)
|
||||
val lastLineEndIndex = layout.getLineEnd(line - 1, visibleEnd = true)
|
||||
return subSequence(lastLineStartIndex, lastLineEndIndex)
|
||||
}
|
||||
|
||||
private inline fun AnnotatedString.calculateReplaceCountToBeSingleLineWith(
|
||||
maximumTextWidth: Int,
|
||||
measureTextWidth: (subText: AnnotatedString) -> Int,
|
||||
): Int {
|
||||
var replacedTextWidth: Int
|
||||
var replacedCount = -1
|
||||
do {
|
||||
replacedCount++
|
||||
replacedTextWidth = measureTextWidth(
|
||||
subSequence(0, this.length - replacedCount),
|
||||
)
|
||||
} while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth)
|
||||
|
||||
val lastVisibleChar: Char? = this.getOrNull(this.length - replacedCount - 1)
|
||||
val firstOverflowChar: Char? = this.getOrNull(this.length - replacedCount)
|
||||
if (lastVisibleChar?.isSurrogate() == true && firstOverflowChar?.isHighSurrogate() == false) {
|
||||
val subText = subSequence(0, this.length - replacedCount)
|
||||
if (subText.isNotEmpty()) {
|
||||
return length - subText.indexOfLast { it.isHighSurrogate() }
|
||||
}
|
||||
}
|
||||
return replacedCount
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class ToggleArea private constructor(internal val value: Int) {
|
||||
|
||||
override fun toString(): String {
|
||||
return when (this) {
|
||||
All -> "All"
|
||||
More -> "More"
|
||||
else -> "Invalid"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* All area of the text is clickable to toggle.
|
||||
*/
|
||||
@Stable
|
||||
val All: ToggleArea = ToggleArea(1)
|
||||
|
||||
/**
|
||||
* 'More' and 'Less' area of the text is clickable to toggle.
|
||||
*/
|
||||
@Stable
|
||||
val More: ToggleArea = ToggleArea(2)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class ReadMoreTextOverflow private constructor(internal val value: Int) {
|
||||
|
||||
override fun toString(): String {
|
||||
return when (this) {
|
||||
Clip -> "Clip"
|
||||
Ellipsis -> "Ellipsis"
|
||||
else -> "Invalid"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Clip the overflowing text to fix its container.
|
||||
*/
|
||||
@Stable
|
||||
val Clip: ReadMoreTextOverflow = ReadMoreTextOverflow(1)
|
||||
|
||||
/**
|
||||
* Use an ellipsis to indicate that the text has overflowed.
|
||||
*/
|
||||
@Stable
|
||||
val Ellipsis: ReadMoreTextOverflow = ReadMoreTextOverflow(2)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
|
@ -19,12 +20,13 @@ fun CurrencyIconTopBadge(
|
|||
alpha: Float,
|
||||
colorFilter: ColorFilter?,
|
||||
modifier: Modifier = Modifier,
|
||||
background: Color = TangemTheme.colors.background.primary,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(TangemTheme.dimens.size18)
|
||||
.background(
|
||||
color = TangemTheme.colors.background.primary,
|
||||
color = background,
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.data.feedback.converters
|
||||
|
||||
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
|
||||
import com.tangem.domain.common.TapWorkarounds.isVisa
|
||||
import com.tangem.domain.common.util.getBackupCardsCount
|
||||
import com.tangem.domain.feedback.models.CardInfo
|
||||
import com.tangem.domain.models.scan.CardDTO
|
||||
|
|
@ -29,6 +30,7 @@ internal object CardInfoConverter : Converter<ScanResponse, CardInfo> {
|
|||
},
|
||||
isImported = value.card.wallets.any(CardDTO.Wallet::isImported),
|
||||
isStart2Coin = value.card.isStart2Coin,
|
||||
isVisa = value.card.isVisa,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ internal class NetworksStatusesStoreInitializationTest {
|
|||
|
||||
DefaultNetworksStatusesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore, // local mock
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
|
|
|
|||
1
data/quotes/.gitignore
vendored
Normal file
1
data/quotes/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
36
data/quotes/build.gradle.kts
Normal file
36
data/quotes/build.gradle.kts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.quotes"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.tokens)
|
||||
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.wallets.models)
|
||||
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.timber)
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package com.tangem.data.quotes.multi
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.common.api.safeApiCallWithTimeout
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiQuoteFetcher]
|
||||
*
|
||||
* @property tangemTechApi tangemTech api
|
||||
* @property appCurrencyResponseStore app currency response store
|
||||
* @property quotesStore quotes store
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiQuoteFetcher(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
) : MultiQuoteFetcher {
|
||||
|
||||
private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter()
|
||||
|
||||
override suspend fun invoke(params: MultiQuoteFetcher.Params): Either<Throwable, Unit> = Either.catch {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
|
||||
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(
|
||||
currenciesIds = params.currenciesIds.mapTo(
|
||||
destination = hashSetOf(),
|
||||
transform = CryptoCurrency.RawID::value,
|
||||
),
|
||||
)
|
||||
|
||||
val appCurrency = appCurrencyResponseStore.getSyncOrNull()
|
||||
?: error(message = "Unable to get AppCurrency for updating quotes")
|
||||
|
||||
safeApiCallWithTimeout(
|
||||
call = {
|
||||
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
|
||||
val response = tangemTechApi.getQuotes(currencyId = appCurrency.id, coinIds = coinIds).bind()
|
||||
|
||||
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
|
||||
response = response,
|
||||
filteredIds = replacementIdsResult.idsFiltered,
|
||||
)
|
||||
|
||||
quotesStore.storeActual(values = updatedResponse.quotes)
|
||||
},
|
||||
onError = { error -> throw error },
|
||||
)
|
||||
}
|
||||
.onLeft {
|
||||
Timber.e(it)
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
|
||||
/**
|
||||
* Default implementation of [SingleQuoteProducer]
|
||||
*
|
||||
* @property params params
|
||||
* @property quotesStore quotes store
|
||||
*/
|
||||
internal class DefaultSingleQuoteProducer @AssistedInject constructor(
|
||||
@Assisted val params: SingleQuoteProducer.Params,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
) : SingleQuoteProducer {
|
||||
|
||||
override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
|
||||
override fun produce(): Flow<Quote> {
|
||||
return quotesStore.get()
|
||||
.mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SingleQuoteProducer.Factory {
|
||||
override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.quote.converter.QuoteConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesStoreV2]
|
||||
*
|
||||
* @property runtimeStore runtime store
|
||||
* @property persistenceDataStore persistence store
|
||||
* @param dispatchers dispatchers
|
||||
*/
|
||||
internal class DefaultQuotesStoreV2(
|
||||
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
|
||||
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : QuotesStoreV2 {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val cachedStatuses = persistenceDataStore.data.firstOrNull()
|
||||
|
||||
if (cachedStatuses.isNullOrEmpty()) return@launch
|
||||
|
||||
runtimeStore.store(
|
||||
value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
|
||||
|
||||
override suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE)
|
||||
}
|
||||
|
||||
override suspend fun storeActual(values: Map<String, QuotesResponse.Quote>) {
|
||||
coroutineScope {
|
||||
launch {
|
||||
val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries)
|
||||
storeInRuntimeStore(values = quotes)
|
||||
}
|
||||
launch { storeInPersistenceStore(values = values) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE)
|
||||
}
|
||||
|
||||
private suspend fun updateStatusSourceInRuntime(currenciesIds: Set<CryptoCurrency.RawID>, source: StatusSource) {
|
||||
runtimeStore.update(default = emptySet()) { stored ->
|
||||
val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id ->
|
||||
val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id)
|
||||
|
||||
quote.copySealed(source = source)
|
||||
}
|
||||
|
||||
stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInRuntimeStore(values: Set<Quote>) {
|
||||
runtimeStore.update(default = emptySet()) { saved ->
|
||||
saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistenceStore(values: Map<String, QuotesResponse.Quote>) {
|
||||
persistenceDataStore.updateData { storedQuotes -> storedQuotes + values }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Store of [Quote]'es set */
|
||||
internal interface QuotesStoreV2 {
|
||||
|
||||
/** Get flow of quotes */
|
||||
fun get(): Flow<Set<Quote>>
|
||||
|
||||
/** Refresh status of [currenciesIds] */
|
||||
suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>)
|
||||
|
||||
/** Store actual map of currency ids and quotes [values] */
|
||||
suspend fun storeActual(values: Map<String, QuotesResponse.Quote>)
|
||||
|
||||
/** Store error for [currenciesIds] */
|
||||
suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>)
|
||||
}
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
package com.tangem.data.quotes.multi
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiQuoteFetcherTest {
|
||||
|
||||
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
|
||||
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>(relaxed = true)
|
||||
private val quotesStore = mockk<QuotesStoreV2>(relaxed = true)
|
||||
|
||||
private val fetcher = DefaultMultiQuoteFetcher(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStore = quotesStore,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fetch quotes successfully`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
val coinIds = "BTC,ETH"
|
||||
coEvery {
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
} returns ApiResponse.Success(successResponse)
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch quotes failure because api request failed`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
val coinIds = "BTC,ETH"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse<QuotesResponse>
|
||||
coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeActual(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch quotes failure because app currency not found`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
val usdAppCurrency = CurrenciesResponse.Currency(
|
||||
id = "USD".lowercase(),
|
||||
code = "USD",
|
||||
name = "US Dollar",
|
||||
unit = "$",
|
||||
type = "fiat",
|
||||
rateBTC = "",
|
||||
)
|
||||
|
||||
val successResponse = QuotesResponse(
|
||||
quotes = mapOf(
|
||||
"BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
|
||||
"ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultSingleQuoteProducerTest {
|
||||
|
||||
private val params = SingleQuoteProducer.Params(
|
||||
rawCurrencyId = CryptoCurrency.RawID(value = "BTC"),
|
||||
)
|
||||
|
||||
private val quotesStore = mockk<QuotesStoreV2>()
|
||||
|
||||
private val producer = DefaultSingleQuoteProducer(
|
||||
params = params,
|
||||
quotesStore = quotesStore,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test that flow is mapped for network from params`() = runTest {
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val storeQuote = flowOf(
|
||||
setOf(
|
||||
status,
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
verify { quotesStore.get() }
|
||||
|
||||
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(1)
|
||||
Truth.assertThat(values).isEqualTo(listOf(status))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that flow is updated if quote is updated`() = runTest {
|
||||
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
verify { quotesStore.get() }
|
||||
|
||||
// first emit
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
storeQuote.emit(value = setOf(status))
|
||||
|
||||
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
Truth.assertThat(values1).isEqualTo(listOf(status))
|
||||
|
||||
// second emit
|
||||
val updatedStatus = Quote.Value(
|
||||
rawCurrencyId = params.rawCurrencyId,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
)
|
||||
storeQuote.emit(value = setOf(updatedStatus))
|
||||
|
||||
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
|
||||
Truth.assertThat(values2.size).isEqualTo(2)
|
||||
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test that flow is filtered the same status`() = runTest {
|
||||
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
verify { quotesStore.get() }
|
||||
|
||||
// first emit
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
storeQuote.emit(value = setOf(status))
|
||||
|
||||
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
Truth.assertThat(values1).isEqualTo(listOf(status))
|
||||
|
||||
// second emit
|
||||
storeQuote.emit(value = setOf(status))
|
||||
|
||||
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
|
||||
Truth.assertThat(values2.size).isEqualTo(1)
|
||||
Truth.assertThat(values2).isEqualTo(listOf(status))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test if flow throws exception`() = runTest {
|
||||
val exception = IllegalStateException()
|
||||
val status = Quote.Value(
|
||||
rawCurrencyId = params.rawCurrencyId,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
)
|
||||
|
||||
val innerFlow = MutableStateFlow(value = false)
|
||||
val storeQuote = flow {
|
||||
if (innerFlow.value) {
|
||||
emit(setOf(status))
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
.buffer(capacity = 5)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
verify { quotesStore.get() }
|
||||
|
||||
val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
|
||||
|
||||
innerFlow.emit(value = true)
|
||||
|
||||
val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
Truth.assertThat(values2.size).isEqualTo(1)
|
||||
Truth.assertThat(values2).isEqualTo(listOf(status))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test if flow doesn't contain network from params`() = runTest {
|
||||
val storeFlow = flowOf(
|
||||
setOf(
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
every { quotesStore.get() } returns storeFlow
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
verify { quotesStore.get() }
|
||||
|
||||
val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuotesStoreGetMethodTest {
|
||||
|
||||
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
private val store = DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test get if runtime store is empty`() = runTest {
|
||||
val actual = store.get()
|
||||
|
||||
val values = backgroundScope.getEmittedValues(testScheduler, actual)
|
||||
|
||||
Truth.assertThat(values).isEqualTo(emptyList<Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test get if runtime store contains empty set`() = runTest {
|
||||
runtimeStore.store(value = emptySet())
|
||||
|
||||
val actual = store.get()
|
||||
|
||||
val values = backgroundScope.getEmittedValues(testScheduler, actual)
|
||||
|
||||
Truth.assertThat(values).isEqualTo(listOf(emptySet<Quote>()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test get if runtime store is not empty`() = runTest {
|
||||
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
|
||||
|
||||
val actual = store.get()
|
||||
|
||||
val values = backgroundScope.getEmittedValues(testScheduler, actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(1)
|
||||
Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain())))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuotesStoreInitializationTest {
|
||||
|
||||
@Test
|
||||
fun `test initialization if cache store is empty`() = runTest {
|
||||
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
|
||||
|
||||
every { persistenceStore.data } returns emptyFlow()
|
||||
|
||||
DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test initialization if cache store contains empty map`() = runTest {
|
||||
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test initialization if cache store is not empty`() = runTest {
|
||||
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
this += btcQuote
|
||||
this += ethQuote
|
||||
}
|
||||
}
|
||||
|
||||
DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val expected = setOf(
|
||||
btcQuote.toDomain(source = StatusSource.CACHE),
|
||||
ethQuote.toDomain(source = StatusSource.CACHE),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuotesStoreUpdateMethodsTest {
|
||||
|
||||
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
private val store = DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `refresh if runtime store is empty`() = runTest {
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
store.refresh(currenciesIds = currenciesIds)
|
||||
|
||||
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh if runtime store contains quote with this id`() = runTest {
|
||||
val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL)
|
||||
|
||||
runtimeStore.store(value = setOf(quote))
|
||||
|
||||
store.refresh(currenciesIds = setOf(quote.rawCurrencyId))
|
||||
|
||||
val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE))
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store actual if runtime and cache stores contain quotes with this id`() = runTest {
|
||||
val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(
|
||||
value = setOf(
|
||||
prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
|
||||
),
|
||||
)
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
put("BTC", prevStatus)
|
||||
}
|
||||
}
|
||||
|
||||
val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN)
|
||||
|
||||
store.storeActual(values = mapOf("BTC" to newStatus))
|
||||
|
||||
val runtimeExpected = setOf(
|
||||
newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL),
|
||||
)
|
||||
val persistenceExpected = mapOf("BTC" to newStatus)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store is empty`() = runTest {
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
store.storeError(currenciesIds = currenciesIds)
|
||||
|
||||
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store contains status with this network`() = runTest {
|
||||
val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(
|
||||
value = setOf(
|
||||
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE),
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
store.storeError(
|
||||
currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
),
|
||||
)
|
||||
|
||||
val runtimeExpected = setOf(
|
||||
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
|||
/**
|
||||
* Adapter to replace unsupported currencies for quotes request if it necessary
|
||||
*/
|
||||
internal class QuotesUnsupportedCurrenciesIdAdapter {
|
||||
class QuotesUnsupportedCurrenciesIdAdapter {
|
||||
|
||||
/**
|
||||
* Replaces unsupported currencies id to it replacements for request
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ dependencies {
|
|||
|
||||
/* Tangem libraries */
|
||||
implementation(tangemDeps.blockchain)
|
||||
implementation(tangemDeps.card.core)
|
||||
|
||||
/* Reown - WalletConnect */
|
||||
implementation(deps.reownCore) {
|
||||
|
|
@ -47,4 +48,10 @@ dependencies {
|
|||
/* Other */
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
|
||||
/* Tests */
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.turbine)
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase
|
|||
import com.tangem.data.walletconnect.request.DefaultWcRequestService
|
||||
import com.tangem.data.walletconnect.request.WcMethodHandler
|
||||
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
|
||||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.datasource.di.SdkMoshi
|
||||
|
|
@ -25,10 +26,10 @@ import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsReposit
|
|||
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.request.WcRequestService
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
|
|
@ -88,6 +89,7 @@ internal object WalletConnectDataModule {
|
|||
store: WalletConnectStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
legacyStore: WalletConnectSessionsRepository,
|
||||
getUserWallet: GetUserWalletUseCase,
|
||||
getWallets: GetWalletsUseCase,
|
||||
): DefaultWcSessionsManager {
|
||||
val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
|
||||
|
|
@ -96,6 +98,7 @@ internal object WalletConnectDataModule {
|
|||
dispatchers = dispatchers,
|
||||
legacyStore = legacyStore,
|
||||
getWallets = getWallets,
|
||||
getUserWallet = getUserWallet,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
|
@ -131,9 +134,8 @@ internal object WalletConnectDataModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork(
|
||||
fun wcEthNetwork(@SdkMoshi moshi: Moshi): WcEthNetwork = WcEthNetwork(
|
||||
moshi = moshi,
|
||||
respondService = respondService,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@ import com.tangem.data.walletconnect.request.WcMethodHandler
|
|||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.domain.walletconnect.model.WcMethod
|
||||
import com.tangem.domain.walletconnect.model.WcRequest
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.WcUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.EthPersonalSignUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod
|
||||
import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod.SignMessage
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
|
@ -19,7 +17,6 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
|||
|
||||
internal class WcEthNetwork(
|
||||
private val moshi: Moshi,
|
||||
private val respondService: WcRespondService,
|
||||
) : WcMethodHandler<WcEthMethod>, WcUseCasesFlowProvider, WcNamespaceConverter {
|
||||
|
||||
private val _useCases: Channel<WcUseCase> = Channel(Channel.BUFFERED)
|
||||
|
|
@ -46,7 +43,7 @@ internal class WcEthNetwork(
|
|||
override fun handle(wcRequest: WcRequest<WcMethod>) {
|
||||
wcRequest as WcRequest<WcEthMethod>
|
||||
val useCase = when (wcRequest.method) {
|
||||
is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest<SignMessage>, respondService)
|
||||
is SignMessage -> TODO()
|
||||
}
|
||||
_useCases.trySend(useCase)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import com.tangem.data.walletconnect.model.NamespaceKey
|
|||
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
|
||||
import com.tangem.domain.tokens.model.Network
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
|
||||
internal class CaipNamespaceDelegate constructor(
|
||||
private val namespaceConverters: Map<NamespaceKey, WcNamespaceConverter>,
|
||||
|
|
@ -16,7 +16,7 @@ internal class CaipNamespaceDelegate constructor(
|
|||
|
||||
suspend fun associate(
|
||||
sessionProposal: Wallet.Model.SessionProposal,
|
||||
userWalletId: UserWalletId,
|
||||
userWallet: UserWallet,
|
||||
networks: List<Network>,
|
||||
): Map<String, Wallet.Model.Namespace.Session> {
|
||||
val converters = namespaceConverters.values
|
||||
|
|
@ -25,7 +25,7 @@ internal class CaipNamespaceDelegate constructor(
|
|||
|
||||
networks.map { network ->
|
||||
val blockchain = Blockchain.fromId(network.id.value)
|
||||
val address = walletManagersFacade.getDefaultAddress(userWalletId, network)
|
||||
val address = walletManagersFacade.getDefaultAddress(userWallet.walletId, network)
|
||||
val chainId = converters.firstOrNull { it.toCAIP2(blockchain) != null }?.toCAIP2(blockchain)
|
||||
requireNotNull(chainId)
|
||||
requireNotNull(address)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData
|
|||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -93,8 +93,8 @@ internal class DefaultWcPairUseCase(
|
|||
).left()
|
||||
|
||||
is Wallet.Model.SettledSessionResponse.Result -> {
|
||||
val newSession = settledSession.session.toDomain(sessionForApprove.walletId)
|
||||
sessionsManager.saveSession(sessionForApprove.walletId, newSession)
|
||||
val newSession = settledSession.session.toDomain(sessionForApprove.wallet)
|
||||
sessionsManager.saveSession(newSession)
|
||||
newSession.right()
|
||||
}
|
||||
}
|
||||
|
|
@ -148,7 +148,7 @@ internal class DefaultWcPairUseCase(
|
|||
): Either<Throwable, Unit> {
|
||||
val namespaces = caipNamespaceDelegate.associate(
|
||||
sdkSessionProposal,
|
||||
sessionForApprove.walletId,
|
||||
sessionForApprove.wallet,
|
||||
sessionForApprove.network.map { it.network },
|
||||
)
|
||||
val sessionApprove = Wallet.Params.SessionApprove(
|
||||
|
|
@ -209,8 +209,8 @@ internal class DefaultWcPairUseCase(
|
|||
}
|
||||
},)
|
||||
|
||||
private fun Wallet.Model.Session.toDomain(walletId: UserWalletId): WcSession = WcSession(
|
||||
userWalletId = walletId,
|
||||
private fun Wallet.Model.Session.toDomain(wallet: UserWallet): WcSession = WcSession(
|
||||
wallet = wallet,
|
||||
sdkModel = WcSdkSessionConverter.convert(this),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
package com.tangem.data.walletconnect.request
|
||||
|
||||
import com.reown.walletkit.client.Wallet
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.data.walletconnect.utils.WcSdkObserver
|
||||
import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter
|
||||
import com.tangem.domain.walletconnect.model.WcMethod
|
||||
import com.tangem.domain.walletconnect.model.WcRequest
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.walletconnect.request.WcRequestService
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import arrow.core.right
|
|||
import com.reown.walletkit.client.Wallet
|
||||
import com.reown.walletkit.client.WalletKit
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
|
|
@ -50,4 +49,19 @@ internal class DefaultWcRespondService : WcRespondService {
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String) {
|
||||
WalletKit.respondSessionRequest(
|
||||
params = Wallet.Params.SessionRequestResponse(
|
||||
sessionTopic = request.topic,
|
||||
jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError(
|
||||
id = request.request.id,
|
||||
code = 0,
|
||||
message = message,
|
||||
),
|
||||
),
|
||||
onSuccess = {},
|
||||
onError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.domain.walletconnect.respond
|
||||
package com.tangem.data.walletconnect.respond
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
|
|
@ -6,4 +6,5 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
|||
interface WcRespondService {
|
||||
suspend fun respond(request: WcSdkSessionRequest, response: String): Either<Throwable, Unit>
|
||||
suspend fun rejectRequest(request: WcSdkSessionRequest, message: String = ""): Either<Throwable, Unit>
|
||||
fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String = "")
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@ import com.tangem.domain.walletconnect.model.WcSession
|
|||
import com.tangem.domain.walletconnect.model.WcSessionDTO
|
||||
import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -29,6 +30,7 @@ internal class DefaultWcSessionsManager constructor(
|
|||
private val store: WalletConnectStore,
|
||||
private val legacyStore: WalletConnectSessionsRepository,
|
||||
private val getWallets: GetWalletsUseCase,
|
||||
private val getUserWallet: GetUserWalletUseCase,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val scope: CoroutineScope,
|
||||
) : WcSessionsManager, WcSdkObserver {
|
||||
|
|
@ -36,20 +38,22 @@ internal class DefaultWcSessionsManager constructor(
|
|||
private val onSessionDelete = Channel<Wallet.Model.SessionDelete>(capacity = Channel.BUFFERED)
|
||||
private val oneTimeMigration = MutableStateFlow(true)
|
||||
|
||||
override val sessions: Flow<Map<UserWalletId, List<WcSession>>>
|
||||
get() = store.sessions
|
||||
.transform { inStore ->
|
||||
override val sessions: Flow<Map<UserWallet, List<WcSession>>>
|
||||
get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore }
|
||||
.transform { pair ->
|
||||
val (wallets, inStore) = pair
|
||||
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
|
||||
if (oneTimeMigration.value) {
|
||||
oneTimeMigration.value = false
|
||||
val someMigrated = migrateLegacyStore(inStore)
|
||||
val someMigrated = migrateLegacyStore(inStore, inSdk, wallets)
|
||||
if (someMigrated) return@transform // ignore emit, wait next one
|
||||
}
|
||||
val inSdk: List<Wallet.Model.Session> = WalletKit.getListOfActiveSessions()
|
||||
val associatedSessions: List<WcSession> = associateWithSdk(inSdk, inStore)
|
||||
val associatedSessions: List<WcSession> = associate(inSdk, inStore, wallets)
|
||||
val someRemove = removeUnknownSessions(inStore, associatedSessions)
|
||||
if (someRemove) return@transform // ignore emit, wait next one
|
||||
emit(associatedSessions.groupBy { it.userWalletId })
|
||||
emit(associatedSessions.groupBy { it.wallet })
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.io)
|
||||
|
||||
override fun onWcSdkInit() {
|
||||
|
|
@ -57,11 +61,11 @@ internal class DefaultWcSessionsManager constructor(
|
|||
listenOnSessionDelete()
|
||||
}
|
||||
|
||||
override suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) {
|
||||
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.userWalletId))
|
||||
override suspend fun saveSession(session: WcSession) {
|
||||
store.saveSession(WcSessionDTO(session.sdkModel.topic, session.wallet.walletId))
|
||||
}
|
||||
|
||||
override suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either<Throwable, Unit> {
|
||||
override suspend fun removeSession(session: WcSession): Either<Throwable, Unit> {
|
||||
val topic = session.sdkModel.topic
|
||||
val sdkCall = sdkDisconnectSession(topic)
|
||||
sdkCall.onLeft { return it.left() }
|
||||
|
|
@ -82,7 +86,8 @@ internal class DefaultWcSessionsManager constructor(
|
|||
override suspend fun findSessionByTopic(topic: String): WcSession? = withContext(dispatchers.io) {
|
||||
val storedSessions = store.findSessionByTopic(topic) ?: return@withContext null
|
||||
val sdkSession = WalletKit.getActiveSessionByTopic(topic) ?: return@withContext null
|
||||
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
val wallet = getUserWallet.invoke(storedSessions.walletId).getOrNull() ?: return@withContext null
|
||||
WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
}
|
||||
|
||||
override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {
|
||||
|
|
@ -90,28 +95,35 @@ internal class DefaultWcSessionsManager constructor(
|
|||
onSessionDelete.trySend(sessionDelete)
|
||||
}
|
||||
|
||||
private suspend fun migrateLegacyStore(inNewStoreSessions: Set<WcSessionDTO>): Boolean {
|
||||
val walletIds = getWallets.invokeSync().mapTo(mutableSetOf()) { it.walletId }
|
||||
val inLegacyStoreSessions = walletIds
|
||||
private suspend fun migrateLegacyStore(
|
||||
inNewStore: Set<WcSessionDTO>,
|
||||
inSdk: List<Wallet.Model.Session>,
|
||||
wallets: List<UserWallet>,
|
||||
): Boolean {
|
||||
val walletIds = wallets.map { wallet -> wallet.walletId }
|
||||
val inLegacyStore = walletIds
|
||||
.map { walletId ->
|
||||
flow { emit(legacyStore.loadSessions(walletId.stringValue).map { WcSessionDTO(it.topic, walletId) }) }
|
||||
}
|
||||
.merge()
|
||||
.reduce { accumulator, value -> accumulator.plus(value) }
|
||||
// migrate only active legacySessions
|
||||
.filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } }
|
||||
|
||||
val mustSaveInNewStore = inLegacyStoreSessions.subtract(inNewStoreSessions)
|
||||
val mustSaveInNewStore = inLegacyStore.subtract(inNewStore)
|
||||
if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore)
|
||||
return mustSaveInNewStore.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun associateWithSdk(
|
||||
sdkSessions: List<Wallet.Model.Session>,
|
||||
storeSessions: Set<WcSessionDTO>,
|
||||
private fun associate(
|
||||
inSdk: List<Wallet.Model.Session>,
|
||||
inStore: Set<WcSessionDTO>,
|
||||
wallets: List<UserWallet>,
|
||||
): List<WcSession> {
|
||||
val wcSessions = sdkSessions.mapNotNull { sdkSession ->
|
||||
val storedSessions = storeSessions.find { it.topic == sdkSession.topic }
|
||||
?: return@mapNotNull null
|
||||
WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
val wcSessions = inStore.mapNotNull { session ->
|
||||
val wallet = wallets.find { it.walletId == session.walletId } ?: return@mapNotNull null
|
||||
val sdkSession = inSdk.find { it.topic == session.topic } ?: return@mapNotNull null
|
||||
WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession))
|
||||
}
|
||||
return wcSessions
|
||||
}
|
||||
|
|
@ -122,9 +134,6 @@ internal class DefaultWcSessionsManager constructor(
|
|||
val haveSomeUnknown = unknownStoredSessions.isNotEmpty()
|
||||
|
||||
if (haveSomeUnknown) {
|
||||
unknownStoredSessions.forEach { unknown ->
|
||||
legacyStore.removeSession(unknown.walletId.stringValue, unknown.topic)
|
||||
}
|
||||
store.removeSessions(unknownStoredSessions.toSet())
|
||||
}
|
||||
return haveSomeUnknown
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package com.tangem.data.walletconnect.sign
|
||||
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
import com.tangem.data.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
|
||||
internal abstract class BaseWcSignUseCase<MiddleAction, SignModel> :
|
||||
WcMethodUseCase,
|
||||
WcSignUseCase.FinalAction,
|
||||
FinalActionCollector<SignModel>,
|
||||
MiddleActionCollector<MiddleAction, SignModel> {
|
||||
|
||||
abstract val respondService: WcRespondService
|
||||
|
||||
abstract val context: WcMethodUseCaseContext
|
||||
override val session: WcSession get() = context.session
|
||||
override val rawSdkRequest: WcSdkSessionRequest get() = context.rawSdkRequest
|
||||
|
||||
protected val delegate by lazy {
|
||||
WcSignUseCaseDelegate(
|
||||
finalActionCollector = this,
|
||||
middleActionCollector = this,
|
||||
)
|
||||
}
|
||||
|
||||
override val onCancel: suspend (currentState: WcSignState<SignModel>) -> Unit = {
|
||||
defaultReject()
|
||||
}
|
||||
|
||||
override fun sign() = delegate.sign()
|
||||
override fun cancel() = delegate.cancel()
|
||||
protected fun middleAction(action: MiddleAction) = delegate.middleAction(action)
|
||||
|
||||
protected fun defaultReject() {
|
||||
respondService.rejectRequestNonBlock(rawSdkRequest)
|
||||
}
|
||||
}
|
||||
|
||||
internal interface MiddleActionCollector<MiddleAction, SignModel> {
|
||||
|
||||
val onMiddleAction: OnMiddle<MiddleAction, SignModel> get() = { _, _ -> }
|
||||
}
|
||||
|
||||
internal interface FinalActionCollector<SignModel> {
|
||||
|
||||
val onSign: OnSign<SignModel> get() = {}
|
||||
|
||||
val onCancel: OnCancel<SignModel> get() = {}
|
||||
}
|
||||
|
||||
internal class WcMethodUseCaseContext(
|
||||
val session: WcSession,
|
||||
val rawSdkRequest: WcSdkSessionRequest,
|
||||
)
|
||||
|
||||
internal typealias OnSign<SignModel> =
|
||||
suspend FlowCollector<WcSignState<SignModel>>.(state: WcSignState<SignModel>) -> Unit
|
||||
internal typealias OnCancel<SignModel> =
|
||||
suspend (currentState: WcSignState<SignModel>) -> Unit
|
||||
internal typealias OnMiddle<MiddleAction, SignModel> =
|
||||
suspend FlowCollector<SignModel>.(currentState: WcSignState<SignModel>, middleAction: MiddleAction) -> Unit
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.tangem.data.walletconnect.sign
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
|
||||
|
||||
object SignStateConverter {
|
||||
|
||||
internal fun <M> preSign(signModel: M) = WcSignState(signModel, WcSignStep.PreSign)
|
||||
internal fun <M> signing(signModel: M) = WcSignState(signModel, WcSignStep.Signing)
|
||||
internal fun <M> result(result: Either<Throwable, Unit>, signModel: M) =
|
||||
WcSignState(signModel, WcSignStep.Result(result))
|
||||
|
||||
internal fun <M> WcSignState<M>.toPreSign(signModel: M = this.signModel) = copy(
|
||||
signModel = signModel,
|
||||
domainStep = WcSignStep.PreSign,
|
||||
)
|
||||
|
||||
internal fun <M> WcSignState<M>.toSigning(signModel: M = this.signModel) = copy(
|
||||
domainStep = WcSignStep.Signing,
|
||||
signModel = signModel,
|
||||
)
|
||||
|
||||
internal fun <M> WcSignState<M>.toResult(result: Either<Throwable, Unit>, signModel: M = this.signModel) = copy(
|
||||
domainStep = WcSignStep.Result(result),
|
||||
signModel = signModel,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.tangem.data.walletconnect.sign
|
||||
|
||||
import arrow.core.left
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class WcSignUseCaseDelegate<MiddleAction, SignModel>(
|
||||
private val finalActionCollector: FinalActionCollector<SignModel>,
|
||||
private val middleActionCollector: MiddleActionCollector<MiddleAction, SignModel>,
|
||||
) : FinalActionCollector<SignModel> by finalActionCollector,
|
||||
MiddleActionCollector<MiddleAction, SignModel> by middleActionCollector {
|
||||
|
||||
private val middleActionsChannel = Channel<MiddleAction>()
|
||||
private val finalActionsChannel = Channel<Action>()
|
||||
|
||||
fun cancel() {
|
||||
finalActionsChannel.trySend(Action.Cancel)
|
||||
}
|
||||
|
||||
fun sign() {
|
||||
finalActionsChannel.trySend(Action.Sign)
|
||||
}
|
||||
|
||||
fun middleAction(action: MiddleAction) {
|
||||
middleActionsChannel.trySend(action)
|
||||
}
|
||||
|
||||
operator fun invoke(initModel: SignModel) = channelFlow {
|
||||
val state = MutableStateFlow(WcSignState(initModel, WcSignStep.PreSign))
|
||||
|
||||
state
|
||||
.onEach { newState -> channel.send(newState) }
|
||||
.launchIn(this)
|
||||
|
||||
fun listenMiddle() = middleActionsChannel.receiveAsFlow()
|
||||
.buffer()
|
||||
.transform { middleActions -> this.onMiddleAction(state.value, middleActions) }
|
||||
.onEach { updatedModel -> state.update { it.toPreSign(updatedModel) } }
|
||||
.launchIn(this)
|
||||
|
||||
var listenMiddleJob: Job = listenMiddle()
|
||||
|
||||
fun signFlow() = flow { onSign(state.updateAndGet { it.toSigning() }) }
|
||||
.onEach { newState -> state.update { newState } }
|
||||
.catch { exception ->
|
||||
val errorResult = state.value.toResult(exception.left())
|
||||
state.update { errorResult }
|
||||
}
|
||||
|
||||
var signJob: Job? = null
|
||||
|
||||
finalActionsChannel.receiveAsFlow()
|
||||
.transformLatest<Action, Unit> { finalAction ->
|
||||
when (finalAction) {
|
||||
Action.Cancel -> {
|
||||
onCancel.invoke(state.value)
|
||||
channel.close()
|
||||
}
|
||||
Action.Sign -> {
|
||||
val isSigningNow = signJob?.isActive == true
|
||||
if (isSigningNow) return@transformLatest
|
||||
listenMiddleJob.cancel()
|
||||
signJob = launch {
|
||||
signFlow().collect()
|
||||
listenMiddleJob = listenMiddle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
/**
|
||||
* keep flow running to attempt re-signing after an error
|
||||
* or do something after a successful sign
|
||||
*/
|
||||
awaitClose()
|
||||
}
|
||||
|
||||
sealed interface Action {
|
||||
data object Cancel : Action
|
||||
data object Sign : Action
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
package com.tangem.domain.walletconnect
|
||||
|
||||
import app.cash.turbine.test
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.data.walletconnect.sign.FinalActionCollector
|
||||
import com.tangem.data.walletconnect.sign.MiddleActionCollector
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
|
||||
import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning
|
||||
import com.tangem.data.walletconnect.sign.WcSignUseCaseDelegate
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignState
|
||||
import com.tangem.domain.walletconnect.usecase.sign.WcSignStep
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
internal class WcSignUseCaseDelegateTest {
|
||||
|
||||
private val middleActionCollector = mockk<MiddleActionCollector<TestMiddleAction, TestSignModel>>()
|
||||
private val finalActionCollector = mockk<FinalActionCollector<TestSignModel>>()
|
||||
private val useCase = WcSignUseCaseDelegate(
|
||||
finalActionCollector = finalActionCollector,
|
||||
middleActionCollector = middleActionCollector,
|
||||
)
|
||||
private val initSignModel = TestSignModel()
|
||||
|
||||
private val initState = WcSignState(initSignModel, WcSignStep.PreSign)
|
||||
private val signing = initState.toSigning()
|
||||
private val result = signing.toResult(Unit.right())
|
||||
private val testException = RuntimeException("test")
|
||||
|
||||
private val successSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
|
||||
currentState: WcSignState<TestSignModel>,
|
||||
) -> Unit = { state ->
|
||||
delay(2)
|
||||
emit(state.toResult(Unit.right()))
|
||||
}
|
||||
|
||||
private val failedSign: suspend FlowCollector<WcSignState<TestSignModel>>.(
|
||||
currentState: WcSignState<TestSignModel>,
|
||||
) -> Unit
|
||||
get() = { state ->
|
||||
delay(2)
|
||||
emit(state.toResult(testException.left()))
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
every { middleActionCollector.onMiddleAction } returns { _, _ -> }
|
||||
every { finalActionCollector.onSign } returns { }
|
||||
every { finalActionCollector.onCancel } returns { }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke and keep flow running`() = runTest {
|
||||
every { finalActionCollector.onSign } returns successSign
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success sign, keep flow running`() = runTest {
|
||||
every { finalActionCollector.onSign } returns successSign
|
||||
|
||||
useCase.invoke(initModel = initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
assertEquals(result, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed sign, keep flow running`() = runTest {
|
||||
val failedResult = signing.toResult(testException.left())
|
||||
every { finalActionCollector.onSign } returns failedSign
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
assertEquals(failedResult, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed sign and catch unknown exception`() = runTest {
|
||||
val exception = RuntimeException("asd")
|
||||
val expectedErrorState = signing.toResult(exception.left())
|
||||
every { finalActionCollector.onSign } returns {
|
||||
delay(2)
|
||||
throw exception
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
assertEquals(expectedErrorState, awaitItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interrupt signing and complete flow on cancel call`() = runTest {
|
||||
every { finalActionCollector.onSign } returns {
|
||||
delay(5)
|
||||
emit(result)
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.sign()
|
||||
assertEquals(signing, awaitItem())
|
||||
delay(2)
|
||||
useCase.cancel()
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore multi time sign call till signed`() = runTest {
|
||||
var count = 0
|
||||
val startLoading = WcSignState(TestSignModel("startLoading 1"), WcSignStep.Signing)
|
||||
val startLoading2 = WcSignState(TestSignModel("startLoading 2"), WcSignStep.Signing)
|
||||
val expectedSignResult = result
|
||||
|
||||
every { finalActionCollector.onSign } returns {
|
||||
// should emit single time in this test
|
||||
emit(if (count % 2 == 0) startLoading else startLoading2)
|
||||
count = count.inc()
|
||||
delay(10)
|
||||
emit(expectedSignResult)
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
useCase.sign()
|
||||
delay(2)
|
||||
assertEquals(startLoading, expectMostRecentItem())
|
||||
|
||||
// should ignore
|
||||
useCase.sign()
|
||||
delay(2)
|
||||
expectNoEvents()
|
||||
|
||||
// should ignore
|
||||
useCase.sign()
|
||||
expectNoEvents()
|
||||
|
||||
delay(8)
|
||||
assertEquals(expectedSignResult, expectMostRecentItem())
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignore middle actions while signing, on failed collect middle actions again`() = runTest {
|
||||
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
|
||||
val firstMiddleUpdate = WcSignState(
|
||||
signModel = firstTextMode,
|
||||
domainStep = WcSignStep.PreSign,
|
||||
)
|
||||
val startLoading = firstMiddleUpdate.toSigning()
|
||||
val failedSign = startLoading.toResult(testException.left())
|
||||
val thirdMiddleUpdate = WcSignState(
|
||||
signModel = TestSignModel(TestMiddleAction.Three().newTestStr),
|
||||
domainStep = WcSignStep.PreSign,
|
||||
)
|
||||
|
||||
every { finalActionCollector.onSign } returns {
|
||||
delay(6)
|
||||
emit(failedSign)
|
||||
}
|
||||
|
||||
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
|
||||
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
delay(2)
|
||||
useCase.middleAction(TestMiddleAction.One())
|
||||
assertEquals(firstMiddleUpdate, expectMostRecentItem())
|
||||
|
||||
useCase.sign()
|
||||
assertEquals(startLoading, awaitItem())
|
||||
|
||||
// should ignore
|
||||
delay(2)
|
||||
useCase.middleAction(TestMiddleAction.Two())
|
||||
|
||||
delay(3)
|
||||
assertEquals(failedSign, awaitItem())
|
||||
|
||||
// continue listen
|
||||
delay(2)
|
||||
useCase.middleAction(TestMiddleAction.Three())
|
||||
assertEquals(thirdMiddleUpdate, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buffered middle actions and drop on sign call`() = runTest {
|
||||
val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr)
|
||||
val expectedFirst = initState.copy(signModel = firstTextMode)
|
||||
val expectedSecond = initState.copy(signModel = TestSignModel(TestMiddleAction.Two().newTestStr))
|
||||
|
||||
every { finalActionCollector.onSign } returns successSign
|
||||
every { middleActionCollector.onMiddleAction } returns { currentState, middleAction ->
|
||||
emit(currentState.signModel.copy(testStr = middleAction.newTestStr))
|
||||
delay(4)
|
||||
}
|
||||
|
||||
useCase.invoke(initSignModel).test {
|
||||
assertEquals(initState, awaitItem())
|
||||
useCase.middleAction(TestMiddleAction.One())
|
||||
useCase.middleAction(TestMiddleAction.Two())
|
||||
// must be dropped
|
||||
useCase.middleAction(TestMiddleAction.Three())
|
||||
|
||||
// 0 - 4 -> "one" is emitted
|
||||
// 4 - 8 -> "two" is emitted
|
||||
// 8 - 12 -> "Signing" is emitted, "three" ignored
|
||||
delay(2)
|
||||
assertEquals(expectedFirst, awaitItem())
|
||||
delay(4)
|
||||
assertEquals(expectedSecond, awaitItem())
|
||||
|
||||
useCase.sign()
|
||||
delay(4)
|
||||
assertEquals(expectedSecond.toSigning(), awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class TestSignModel(val testStr: String = "testStr")
|
||||
|
||||
internal sealed interface TestMiddleAction {
|
||||
val newTestStr: String
|
||||
|
||||
data class One(override val newTestStr: String = "Middle Action One") : TestMiddleAction
|
||||
data class Two(override val newTestStr: String = "Middle Action Two") : TestMiddleAction
|
||||
data class Three(override val newTestStr: String = "Middle Action Three") : TestMiddleAction
|
||||
}
|
||||
}
|
||||
|
|
@ -15,4 +15,5 @@ dependencies {
|
|||
implementation(projects.core.res)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.visa.models)
|
||||
}
|
||||
|
|
@ -2,12 +2,43 @@ package com.tangem.domain.feedback
|
|||
|
||||
import com.tangem.domain.feedback.models.*
|
||||
import com.tangem.domain.feedback.utils.breakLine
|
||||
import com.tangem.domain.visa.model.VisaTxDetails
|
||||
import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses
|
||||
|
||||
internal class FeedbackDataBuilder {
|
||||
|
||||
private val builder = StringBuilder()
|
||||
|
||||
fun addVisaTxInfo(txDetails: VisaTxDetails) {
|
||||
builder.appendKeyValue("Type", txDetails.type)
|
||||
builder.appendKeyValue("Status", txDetails.status)
|
||||
builder.appendKeyValue("Blockchain amount", txDetails.blockchainAmount.toString())
|
||||
builder.appendKeyValue("Transaction amount", txDetails.transactionAmount.toString())
|
||||
builder.appendKeyValue("Currency code", txDetails.transactionCurrencyCode.toString())
|
||||
builder.appendKeyValue("Merchant name", txDetails.merchantName)
|
||||
builder.appendKeyValue("Merchant city", txDetails.merchantCity)
|
||||
builder.appendKeyValue("Merchant country code", txDetails.merchantCountryCode)
|
||||
builder.appendKeyValue("Merchant category code", txDetails.merchantCategoryCode)
|
||||
|
||||
builder.appendDelimiter()
|
||||
builder.breakLine()
|
||||
builder.append("Requests:")
|
||||
|
||||
txDetails.requests.forEach { request ->
|
||||
builder.appendKeyValue("Type", request.requestType)
|
||||
builder.appendKeyValue("Status", request.requestStatus)
|
||||
builder.appendKeyValue("Blockchain amount", request.blockchainAmount.toString())
|
||||
builder.appendKeyValue("Transaction amount", request.transactionAmount.toString())
|
||||
builder.appendKeyValue("Currency code", request.txCurrencyCode.toString())
|
||||
builder.appendKeyValue("Error code", request.errorCode.toString())
|
||||
builder.appendKeyValue("Date", request.requestDate.toString())
|
||||
builder.appendKeyValue("Transaction hash", request.txHash)
|
||||
builder.appendKeyValue("Transaction status", request.txStatus)
|
||||
builder.appendDelimiter()
|
||||
builder.breakLine()
|
||||
}
|
||||
}
|
||||
|
||||
fun addUserWalletsInfo(userWalletsInfo: UserWalletsInfo) {
|
||||
builder.appendKeyValue("User Wallet ID", userWalletsInfo.selectedUserWalletId)
|
||||
builder.appendKeyValue("Total saved wallets", userWalletsInfo.totalUserWallets.toString())
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.tangem.domain.feedback
|
|||
|
||||
import android.content.res.Resources
|
||||
import com.tangem.core.res.getStringSafe
|
||||
import com.tangem.domain.feedback.models.CardInfo
|
||||
import com.tangem.domain.feedback.models.FeedbackEmail
|
||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||
import com.tangem.domain.feedback.repository.FeedbackRepository
|
||||
|
|
@ -27,7 +26,7 @@ class SendFeedbackEmailUseCase(
|
|||
|
||||
suspend operator fun invoke(type: FeedbackEmailType) {
|
||||
val email = FeedbackEmail(
|
||||
address = getAddress(type.cardInfo),
|
||||
address = getAddress(type),
|
||||
subject = emailSubjectResolver.resolve(type),
|
||||
message = createMessage(type),
|
||||
// Temporally user data is not sent
|
||||
|
|
@ -37,8 +36,12 @@ class SendFeedbackEmailUseCase(
|
|||
feedbackRepository.sendEmail(email)
|
||||
}
|
||||
|
||||
private fun getAddress(cardInfo: CardInfo?): String {
|
||||
return if (cardInfo?.isStart2Coin == true) START2COIN_SUPPORT_EMAIL else TANGEM_SUPPORT_EMAIL
|
||||
private fun getAddress(type: FeedbackEmailType): String {
|
||||
return when {
|
||||
type is FeedbackEmailType.Visa || type.cardInfo?.isVisa == true -> TANGEM_VISA_SUPPORT_EMAIL
|
||||
type.cardInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL
|
||||
else -> TANGEM_SUPPORT_EMAIL
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createMessage(type: FeedbackEmailType): String {
|
||||
|
|
@ -61,12 +64,15 @@ class SendFeedbackEmailUseCase(
|
|||
is FeedbackEmailType.CurrencyDescriptionError,
|
||||
is FeedbackEmailType.PreActivatedWallet,
|
||||
is FeedbackEmailType.CardAttestationFailed,
|
||||
is FeedbackEmailType.Visa.Dispute,
|
||||
-> this
|
||||
is FeedbackEmailType.DirectUserRequest,
|
||||
is FeedbackEmailType.RateCanBeBetter,
|
||||
is FeedbackEmailType.StakingProblem,
|
||||
is FeedbackEmailType.SwapProblem,
|
||||
is FeedbackEmailType.TransactionSendingProblem,
|
||||
is FeedbackEmailType.Visa.Activation,
|
||||
is FeedbackEmailType.Visa.DirectUserRequest,
|
||||
-> {
|
||||
append(resources.getStringSafe(R.string.feedback_data_collection_message))
|
||||
skipLine()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ data class CardInfo(
|
|||
val signedHashesList: List<SignedHashes>,
|
||||
val isImported: Boolean,
|
||||
val isStart2Coin: Boolean,
|
||||
val isVisa: Boolean,
|
||||
) {
|
||||
|
||||
data class SignedHashes(val curve: String, val total: String?)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.tangem.domain.feedback.models
|
||||
|
||||
import com.tangem.domain.visa.model.VisaTxDetails
|
||||
|
||||
/**
|
||||
* Email feedback type
|
||||
*
|
||||
|
|
@ -52,4 +54,15 @@ sealed interface FeedbackEmailType {
|
|||
data object CardAttestationFailed : FeedbackEmailType {
|
||||
override val cardInfo: CardInfo? = null
|
||||
}
|
||||
|
||||
sealed class Visa : FeedbackEmailType {
|
||||
data class DirectUserRequest(override val cardInfo: CardInfo) : Visa()
|
||||
|
||||
data class Activation(override val cardInfo: CardInfo) : Visa()
|
||||
|
||||
data class Dispute(
|
||||
val visaTxDetails: VisaTxDetails,
|
||||
override val cardInfo: CardInfo,
|
||||
) : Visa()
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.tangem.domain.feedback.FeedbackDataBuilder
|
|||
import com.tangem.domain.feedback.models.CardInfo
|
||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||
import com.tangem.domain.feedback.repository.FeedbackRepository
|
||||
import com.tangem.domain.visa.model.VisaTxDetails
|
||||
|
||||
/**
|
||||
* Email message body resolver
|
||||
|
|
@ -29,11 +30,20 @@ internal class EmailMessageBodyResolver(
|
|||
is FeedbackEmailType.ScanningProblem,
|
||||
is FeedbackEmailType.CardAttestationFailed,
|
||||
-> addPhoneInfoBody()
|
||||
is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.cardInfo)
|
||||
is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.cardInfo)
|
||||
is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.cardInfo, type.visaTxDetails)
|
||||
}
|
||||
|
||||
return build()
|
||||
}
|
||||
|
||||
private suspend fun FeedbackDataBuilder.addVisaRequestBody(cardInfo: CardInfo, visaTxDetails: VisaTxDetails) {
|
||||
addUserRequestBody(cardInfo)
|
||||
addDelimiter()
|
||||
addVisaTxInfo(visaTxDetails)
|
||||
}
|
||||
|
||||
private suspend fun FeedbackDataBuilder.addUserRequestBody(cardInfo: CardInfo) {
|
||||
addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(cardInfo.userWalletId))
|
||||
addDelimiter()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ internal class EmailMessageTitleResolver(private val resources: Resources) {
|
|||
is FeedbackEmailType.DirectUserRequest,
|
||||
is FeedbackEmailType.CurrencyDescriptionError,
|
||||
is FeedbackEmailType.CardAttestationFailed,
|
||||
is FeedbackEmailType.Visa.Activation,
|
||||
is FeedbackEmailType.Visa.DirectUserRequest,
|
||||
is FeedbackEmailType.Visa.Dispute,
|
||||
-> R.string.feedback_preface_support
|
||||
is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative
|
||||
is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ internal class EmailSubjectResolver(private val resources: Resources) {
|
|||
resources.getStringSafe(R.string.feedback_token_description_error)
|
||||
}
|
||||
FeedbackEmailType.CardAttestationFailed -> "Card attestation failed"
|
||||
is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}"
|
||||
is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}"
|
||||
is FeedbackEmailType.Visa.Dispute -> "[Visa] [DISPUTE] {auto-filled subject}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,4 +3,5 @@ package com.tangem.domain.feedback.utils
|
|||
internal const val GMAIL_MAX_FILE_SIZE = 24_900_000 // ≈ 25 MB
|
||||
|
||||
internal const val START2COIN_SUPPORT_EMAIL = "cardsupport@start2coin.com"
|
||||
internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com"
|
||||
internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com"
|
||||
internal const val TANGEM_VISA_SUPPORT_EMAIL = "visa-support@tangem.com" // [REDACTED_TODO_COMMENT]
|
||||
|
|
@ -55,6 +55,14 @@ data class NFTAsset(
|
|||
override val stringValue: String = tokenAddress
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Solana(
|
||||
val tokenAddress: String,
|
||||
val cnft: Boolean,
|
||||
) : Identifier() {
|
||||
override val stringValue: String = tokenAddress
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data object Unknown : Identifier() {
|
||||
override val stringValue: String = ""
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ data class NFTCollection(
|
|||
@Serializable
|
||||
data class TON(val contractAddress: String?) : Identifier()
|
||||
|
||||
@Serializable
|
||||
data class Solana(val collection: String?) : Identifier()
|
||||
|
||||
@Serializable
|
||||
data object Unknown : Identifier()
|
||||
}
|
||||
|
|
|
|||
1
domain/quotes/.gitignore
vendored
Normal file
1
domain/quotes/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
9
domain/quotes/build.gradle.kts
Normal file
9
domain/quotes/build.gradle.kts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
plugins {
|
||||
alias(deps.plugins.kotlin.jvm)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.core)
|
||||
api(projects.domain.tokens.models)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.domain.quotes.multi
|
||||
|
||||
import com.tangem.domain.core.flow.FlowFetcher
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
|
||||
/**
|
||||
* Fetcher of quotes
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface MultiQuoteFetcher : FlowFetcher<MultiQuoteFetcher.Params> {
|
||||
|
||||
data class Params(val currenciesIds: Set<CryptoCurrency.RawID>)
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.domain.quotes.single
|
||||
|
||||
import com.tangem.domain.core.flow.FlowProducer
|
||||
import com.tangem.domain.tokens.model.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
|
||||
/**
|
||||
* Producer of quote [CryptoCurrency.RawID]
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
interface SingleQuoteProducer : FlowProducer<Quote> {
|
||||
|
||||
data class Params(val rawCurrencyId: CryptoCurrency.RawID)
|
||||
|
||||
interface Factory : FlowProducer.Factory<Params, SingleQuoteProducer>
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.domain.quotes.single
|
||||
|
||||
import com.tangem.domain.core.flow.FlowCachingSupplier
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
|
||||
/**
|
||||
* Supplier of quote [SingleQuoteProducer.Params]
|
||||
*
|
||||
* @property factory factory for creating [SingleQuoteProducer]
|
||||
* @property keyCreator key creator
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
abstract class SingleQuoteSupplier(
|
||||
override val factory: SingleQuoteProducer.Factory,
|
||||
override val keyCreator: (SingleQuoteProducer.Params) -> String,
|
||||
) : FlowCachingSupplier<SingleQuoteProducer, SingleQuoteProducer.Params, Quote>()
|
||||
|
|
@ -7,6 +7,13 @@ sealed interface Quote {
|
|||
|
||||
val rawCurrencyId: CryptoCurrency.RawID
|
||||
|
||||
fun copySealed(source: StatusSource): Quote {
|
||||
return when (this) {
|
||||
is Empty -> this
|
||||
is Value -> copy(source = source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents unknown financial information for a specific cryptocurrency.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ dependencies {
|
|||
ksp(deps.moshi.kotlin.codegen)
|
||||
implementation(deps.moshi.adapters)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(deps.jodatime)
|
||||
implementation(projects.core.error)
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
package com.tangem.domain.walletconnect.model
|
||||
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
|
||||
data class WcSession(
|
||||
val userWalletId: UserWalletId,
|
||||
val wallet: UserWallet,
|
||||
val sdkModel: WcSdkSession,
|
||||
)
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
package com.tangem.domain.walletconnect.model
|
||||
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
|
||||
data class WcSessionApprove(
|
||||
val walletId: UserWalletId,
|
||||
val wallet: UserWallet,
|
||||
val network: List<WcNetwork.Supported>,
|
||||
)
|
||||
|
|
@ -2,12 +2,12 @@ package com.tangem.domain.walletconnect.repository
|
|||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface WcSessionsManager {
|
||||
val sessions: Flow<Map<UserWalletId, List<WcSession>>>
|
||||
suspend fun saveSession(userWalletId: UserWalletId, session: WcSession)
|
||||
suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either<Throwable, Unit>
|
||||
val sessions: Flow<Map<UserWallet, List<WcSession>>>
|
||||
suspend fun saveSession(session: WcSession)
|
||||
suspend fun removeSession(session: WcSession): Either<Throwable, Unit>
|
||||
suspend fun findSessionByTopic(topic: String): WcSession?
|
||||
}
|
||||
|
|
@ -2,16 +2,16 @@ package com.tangem.domain.walletconnect.usecase
|
|||
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.walletconnect.repository.WcSessionsManager
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class WcSessionsUseCase(private val sessionsManager: WcSessionsManager) {
|
||||
operator fun invoke(): Flow<Map<UserWalletId, List<WcSession>>> {
|
||||
operator fun invoke(): Flow<Map<UserWallet, List<WcSession>>> {
|
||||
return sessionsManager.sessions
|
||||
}
|
||||
|
||||
suspend fun invokeSync(): Map<UserWalletId, List<WcSession>> {
|
||||
suspend fun invokeSync(): Map<UserWallet, List<WcSession>> {
|
||||
return sessionsManager.sessions.first()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.tangem.domain.walletconnect.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface WcSimpleSignUseCase<SignModel, MiddleAction> : WcUseCase {
|
||||
|
||||
fun signFlow(initModel: SignModel): Flow<State<SignModel>>
|
||||
|
||||
fun action(action: MiddleAction)
|
||||
|
||||
fun cancel()
|
||||
fun sign(toSign: SignModel)
|
||||
|
||||
sealed interface State<SignModel> {
|
||||
val model: SignModel
|
||||
|
||||
data class PreSign<SignModel>(override val model: SignModel) : State<SignModel>
|
||||
data class Signing<SignModel>(override val model: SignModel) : State<SignModel>
|
||||
data class Result<SignModel>(
|
||||
val result: Either<Throwable, Unit>,
|
||||
override val model: SignModel,
|
||||
) : State<SignModel>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,11 @@
|
|||
package com.tangem.domain.walletconnect.usecase
|
||||
|
||||
interface WcUseCase
|
||||
import com.tangem.domain.walletconnect.model.WcSession
|
||||
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
|
||||
|
||||
interface WcUseCase
|
||||
|
||||
interface WcMethodUseCase : WcUseCase {
|
||||
val session: WcSession
|
||||
val rawSdkRequest: WcSdkSessionRequest
|
||||
}
|
||||
|
|
@ -25,6 +25,6 @@ class WcDisconnectUseCase(
|
|||
}
|
||||
|
||||
suspend fun disconnect(session: WcSession) {
|
||||
sessionsManager.removeSession(session.userWalletId, session)
|
||||
sessionsManager.removeSession(session)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
package com.tangem.domain.walletconnect.usecase.ethereum
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.walletconnect.model.WcRequest
|
||||
import com.tangem.domain.walletconnect.respond.WcRespondService
|
||||
import com.tangem.domain.walletconnect.usecase.WcUseCase
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
class EthPersonalSignUseCase(
|
||||
private val wcRequest: WcRequest<WcEthMethod.SignMessage>,
|
||||
private val respondService: WcRespondService,
|
||||
) : WcUseCase {
|
||||
|
||||
private val onCallTerminalAction = Channel<TerminalAction>()
|
||||
|
||||
fun signFlow(): Flow<State> = flow {
|
||||
val model = SignModel(wcRequest.method.raw)
|
||||
emit(State.PreSign(model))
|
||||
|
||||
when (val action = onCallTerminalAction.receiveAsFlow().first()) {
|
||||
TerminalAction.Cancel -> {
|
||||
val result = respondService.rejectRequest(wcRequest.rawSdkRequest)
|
||||
emit(State.Result(result, model))
|
||||
}
|
||||
is TerminalAction.Sign -> {
|
||||
emit(State.Signing(action.toSign))
|
||||
val signed = signAndPrepareForSend()
|
||||
val result =
|
||||
if (signed != null) {
|
||||
respondService.respond(wcRequest.rawSdkRequest, signed)
|
||||
} else {
|
||||
respondService.rejectRequest(wcRequest.rawSdkRequest)
|
||||
}
|
||||
emit(State.Result(result, model))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FunctionOnlyReturningConstant") // todo(wc) remove later
|
||||
private suspend fun signAndPrepareForSend(): String? {
|
||||
return null // todo(wc)
|
||||
}
|
||||
|
||||
fun sign(toSign: SignModel) {
|
||||
onCallTerminalAction.trySend(TerminalAction.Sign(toSign))
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
onCallTerminalAction.trySend(TerminalAction.Cancel)
|
||||
}
|
||||
|
||||
sealed interface TerminalAction {
|
||||
data class Sign(val toSign: SignModel) : TerminalAction
|
||||
data object Cancel : TerminalAction
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
val model: SignModel
|
||||
|
||||
data class PreSign(override val model: SignModel) : State
|
||||
data class Signing(override val model: SignModel) : State
|
||||
data class Result(
|
||||
val result: Either<Throwable, Unit>,
|
||||
override val model: SignModel,
|
||||
) : State
|
||||
}
|
||||
|
||||
data class SignModel(
|
||||
val raw: List<String>,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.domain.walletconnect.usecase.sign
|
||||
|
||||
import arrow.core.Either
|
||||
|
||||
data class WcSignState<SignModel>(
|
||||
val signModel: SignModel,
|
||||
val domainStep: WcSignStep,
|
||||
)
|
||||
|
||||
sealed interface WcSignStep {
|
||||
data object PreSign : WcSignStep
|
||||
data object Signing : WcSignStep
|
||||
data class Result(val result: Either<Throwable, Unit>) : WcSignStep
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.domain.walletconnect.usecase.sign
|
||||
|
||||
import com.tangem.domain.walletconnect.usecase.WcMethodUseCase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface WcSignUseCase : WcMethodUseCase {
|
||||
|
||||
interface FinalAction {
|
||||
fun cancel()
|
||||
fun sign()
|
||||
}
|
||||
|
||||
interface SimpleRun<SignModel> {
|
||||
operator fun invoke(): Flow<WcSignState<SignModel>>
|
||||
}
|
||||
|
||||
interface ArgsRun<SignModel, Args> {
|
||||
operator fun invoke(args: Args): Flow<WcSignState<SignModel>>
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.core.decompose.navigation.DummyRouter
|
||||
import com.tangem.core.navigation.url.DummyUrlOpener
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.features.details.component.DetailsComponent
|
||||
import com.tangem.features.details.entity.DetailsFooterUM
|
||||
import com.tangem.features.details.entity.DetailsUM
|
||||
|
|
@ -28,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
|
|||
val previewState = DetailsUM(
|
||||
items = previewBlocks,
|
||||
footer = previewFooter,
|
||||
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty,
|
||||
popBack = { /* no-op */ },
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package com.tangem.features.details.entity
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
internal data class DetailsUM(
|
||||
val items: ImmutableList<DetailsItemUM>,
|
||||
val footer: DetailsFooterUM,
|
||||
val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig,
|
||||
val popBack: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.features.details.entity
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.details.impl.R
|
||||
|
||||
internal data class SelectEmailFeedbackTypeBS(
|
||||
val onOptionClick: (Option) -> Unit,
|
||||
) : TangemBottomSheetConfigContent {
|
||||
|
||||
enum class Option(val text: TextReference) {
|
||||
General(resourceReference(R.string.common_contact_tangem_support)),
|
||||
Visa(resourceReference(R.string.common_contact_visa_support)),
|
||||
}
|
||||
}
|
||||
|
|
@ -7,17 +7,22 @@ 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.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.domain.common.TapWorkarounds.isVisa
|
||||
import com.tangem.domain.feedback.GetCardInfoUseCase
|
||||
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
|
||||
import com.tangem.domain.feedback.models.CardInfo
|
||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||
import com.tangem.domain.redux.LegacyAction
|
||||
import com.tangem.domain.redux.ReduxStateHolder
|
||||
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.features.details.component.DetailsComponent
|
||||
import com.tangem.features.details.entity.DetailsFooterUM
|
||||
import com.tangem.features.details.entity.DetailsItemUM
|
||||
import com.tangem.features.details.entity.DetailsUM
|
||||
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
|
||||
import com.tangem.features.details.utils.ItemsBuilder
|
||||
import com.tangem.features.details.utils.SocialsBuilder
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -47,6 +52,7 @@ internal class DetailsModel @Inject constructor(
|
|||
private val appStateHolder: ReduxStateHolder,
|
||||
private val getCardInfoUseCase: GetCardInfoUseCase,
|
||||
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
|
||||
private val getWalletsUseCase: GetWalletsUseCase,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
) : Model() {
|
||||
|
||||
|
|
@ -84,6 +90,7 @@ internal class DetailsModel @Inject constructor(
|
|||
socials = socialsBuilder.buildAll(),
|
||||
appVersion = getAppVersion(),
|
||||
),
|
||||
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty,
|
||||
popBack = router::pop,
|
||||
),
|
||||
)
|
||||
|
|
@ -99,12 +106,88 @@ internal class DetailsModel @Inject constructor(
|
|||
|
||||
private fun sendFeedback() {
|
||||
modelScope.launch {
|
||||
val userWallets = getWalletsUseCase.invokeSync()
|
||||
|
||||
val scanResponse = getSelectedWalletSyncUseCase().getOrNull()?.scanResponse
|
||||
?: error("Selected wallet is null")
|
||||
|
||||
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
|
||||
|
||||
sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(cardInfo = cardInfo))
|
||||
val feedbackType = when {
|
||||
userWallets.all { it.scanResponse.card.isVisa } -> FeedbackEmailType.Visa.DirectUserRequest(cardInfo)
|
||||
userWallets.all { it.scanResponse.card.isVisa.not() } -> FeedbackEmailType.DirectUserRequest(cardInfo)
|
||||
else -> {
|
||||
showFeedbackEmailTypeOptionBS(cardInfo)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
sendFeedbackEmailUseCase(feedbackType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFeedbackEmailTypeOptionBS(selectedCardInfo: CardInfo) {
|
||||
state.update {
|
||||
it.copy(
|
||||
selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = {
|
||||
state.update {
|
||||
it.copy(
|
||||
selectFeedbackEmailTypeBSConfig =
|
||||
it.selectFeedbackEmailTypeBSConfig.copy(isShown = false),
|
||||
)
|
||||
}
|
||||
},
|
||||
content = SelectEmailFeedbackTypeBS(
|
||||
onOptionClick = { option ->
|
||||
onEmailFeedbackTypeOptionSelected(
|
||||
selectedCardInfo = selectedCardInfo,
|
||||
option = option,
|
||||
)
|
||||
|
||||
state.update {
|
||||
it.copy(
|
||||
selectFeedbackEmailTypeBSConfig =
|
||||
it.selectFeedbackEmailTypeBSConfig.copy(isShown = false),
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEmailFeedbackTypeOptionSelected(
|
||||
selectedCardInfo: CardInfo,
|
||||
option: SelectEmailFeedbackTypeBS.Option,
|
||||
) {
|
||||
modelScope.launch {
|
||||
val feedbackType = when (option) {
|
||||
SelectEmailFeedbackTypeBS.Option.General -> {
|
||||
if (selectedCardInfo.isVisa.not()) {
|
||||
FeedbackEmailType.DirectUserRequest(selectedCardInfo)
|
||||
} else {
|
||||
val scanResponse = getWalletsUseCase.invokeSync()
|
||||
.firstOrNull { it.scanResponse.card.isVisa.not() }?.scanResponse ?: return@launch
|
||||
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
|
||||
FeedbackEmailType.DirectUserRequest(cardInfo)
|
||||
}
|
||||
}
|
||||
SelectEmailFeedbackTypeBS.Option.Visa -> {
|
||||
if (selectedCardInfo.isVisa) {
|
||||
FeedbackEmailType.Visa.DirectUserRequest(selectedCardInfo)
|
||||
} else {
|
||||
val scanResponse = getWalletsUseCase.invokeSync()
|
||||
.firstOrNull { it.scanResponse.card.isVisa }?.scanResponse ?: return@launch
|
||||
val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch
|
||||
FeedbackEmailType.Visa.DirectUserRequest(cardInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendFeedbackEmailUseCase(feedbackType)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ internal fun DetailsScreen(
|
|||
userWalletListBlockContent = userWalletListBlockContent,
|
||||
)
|
||||
}
|
||||
|
||||
SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
package com.tangem.features.details.ui
|
||||
|
||||
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.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.inputrow.InputRowChecked
|
||||
import com.tangem.core.ui.components.inputrow.inner.DividerContainer
|
||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
|
||||
import com.tangem.features.details.impl.R
|
||||
|
||||
@Composable
|
||||
internal fun SelectFeedbackEmailTypeBottomSheet(config: TangemBottomSheetConfig) {
|
||||
TangemBottomSheet<SelectEmailFeedbackTypeBS>(
|
||||
config = config,
|
||||
titleText = resourceReference(R.string.common_choose_action),
|
||||
containerColor = TangemTheme.colors.background.tertiary,
|
||||
content = { Content(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(content: SelectEmailFeedbackTypeBS) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
end = TangemTheme.dimens.spacing16,
|
||||
bottom = TangemTheme.dimens.spacing16,
|
||||
),
|
||||
) {
|
||||
SelectEmailFeedbackTypeBS.Option.entries.forEachIndexed { index, type ->
|
||||
DividerContainer(
|
||||
modifier = Modifier
|
||||
.roundedShapeItemDecoration(
|
||||
currentIndex = index,
|
||||
lastIndex = SelectEmailFeedbackTypeBS.Option.entries.lastIndex,
|
||||
addDefaultPadding = false,
|
||||
)
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.clickable { content.onOptionClick(type) },
|
||||
showDivider = index != SelectEmailFeedbackTypeBS.Option.entries.lastIndex,
|
||||
) {
|
||||
InputRowChecked(
|
||||
text = type.text,
|
||||
checked = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.features.nft.component
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.nft.models.NFTAsset
|
||||
|
||||
interface NFTAssetTraitsComponent : ComposableContentComponent {
|
||||
|
||||
data class Params(
|
||||
val nftAsset: NFTAsset,
|
||||
)
|
||||
|
||||
interface Factory : ComponentFactory<Params, NFTAssetTraitsComponent>
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.features.nft.component
|
||||
|
||||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.nft.models.NFTAsset
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
|
||||
interface NFTDetailsBlockComponent : ComposableContentComponent {
|
||||
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val nftAsset: NFTAsset,
|
||||
val nftCollectionName: String,
|
||||
)
|
||||
|
||||
interface Factory : ComponentFactory<Params, NFTDetailsBlockComponent>
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ interface NFTDetailsComponent : ComposableContentComponent {
|
|||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val nftAsset: NFTAsset,
|
||||
val nftCollectionName: String,
|
||||
)
|
||||
|
||||
interface Factory : ComponentFactory<Params, NFTDetailsComponent>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ internal class UpdateDataStateTransformer(
|
|||
private val onRetryClick: () -> Unit,
|
||||
private val onExpandCollectionClick: (NFTCollection) -> Unit,
|
||||
private val onRetryAssetsClick: (NFTCollection) -> Unit,
|
||||
private val onAssetClick: (NFTAsset) -> Unit,
|
||||
private val onAssetClick: (NFTAsset, String) -> Unit,
|
||||
private val initialSearchBarFactory: () -> SearchBarUM,
|
||||
) : Transformer<NFTCollectionsStateUM> {
|
||||
|
||||
|
|
@ -105,12 +105,12 @@ internal class UpdateDataStateTransformer(
|
|||
is NFTCollection.Assets.Value -> NFTCollectionAssetsListUM.Content(
|
||||
items = assets
|
||||
.items
|
||||
.map { it.transform() }
|
||||
.map { it.transform(name.orEmpty()) }
|
||||
.toPersistentList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun NFTAsset.transform(): NFTCollectionAssetUM = NFTCollectionAssetUM(
|
||||
private fun NFTAsset.transform(collectionName: String): NFTCollectionAssetUM = NFTCollectionAssetUM(
|
||||
id = id.toString(),
|
||||
name = name.orEmpty(),
|
||||
imageUrl = media?.url,
|
||||
|
|
@ -121,7 +121,7 @@ internal class UpdateDataStateTransformer(
|
|||
is NFTSalePrice.Value -> NFTSalePriceUM.Content(salePrice.value.toString())
|
||||
},
|
||||
onItemClick = {
|
||||
onAssetClick(this)
|
||||
onAssetClick(this, collectionName)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -115,11 +115,12 @@ internal class NFTCollectionsModel @Inject constructor(
|
|||
// TODO refresh all
|
||||
}
|
||||
|
||||
private fun onAssetClick(asset: NFTAsset) {
|
||||
private fun onAssetClick(asset: NFTAsset, collectionName: String) {
|
||||
router.push(
|
||||
AppRoute.NFTDetails(
|
||||
userWalletId = params.userWalletId,
|
||||
nftAsset = asset,
|
||||
collectionName = collectionName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.tangem.features.nft.collections.ui
|
|||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -11,25 +10,19 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge
|
||||
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 com.tangem.features.nft.collections.entity.NFTCollectionAssetsListUM
|
||||
import com.tangem.features.nft.collections.entity.NFTCollectionUM
|
||||
import com.tangem.features.nft.common.ui.NFTLogo
|
||||
import com.tangem.features.nft.impl.R
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -52,7 +45,11 @@ internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier
|
|||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Logo(state)
|
||||
NFTLogo(
|
||||
imageUrl = state.logoUrl,
|
||||
networkIconId = state.networkIconId,
|
||||
background = TangemTheme.colors.background.primary,
|
||||
)
|
||||
|
||||
Text(state)
|
||||
|
||||
|
|
@ -81,46 +78,6 @@ internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Logo(state: NFTCollectionUM) {
|
||||
val networkBadgeOffset = TangemTheme.dimens.spacing6
|
||||
|
||||
Box(
|
||||
modifier = Modifier,
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.size(TangemTheme.dimens.size36)
|
||||
.clip(TangemTheme.shapes.roundedCorners8),
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(state.logoUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
loading = {
|
||||
RectangleShimmer(radius = TangemTheme.dimens.radius8)
|
||||
},
|
||||
error = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape = TangemTheme.shapes.roundedCorners8)
|
||||
.background(TangemTheme.colors.field.primary),
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
CurrencyIconTopBadge(
|
||||
modifier = Modifier
|
||||
.offset(x = networkBadgeOffset, y = -networkBadgeOffset)
|
||||
.align(Alignment.TopEnd),
|
||||
iconResId = state.networkIconId,
|
||||
alpha = 1f,
|
||||
colorFilter = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.Text(state: NFTCollectionUM) {
|
||||
Column(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
package com.tangem.features.nft.common.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Composable
|
||||
internal fun NFTLogo(imageUrl: String?, @DrawableRes networkIconId: Int, background: Color = Color.Transparent) {
|
||||
val networkBadgeOffset = TangemTheme.dimens.spacing6
|
||||
|
||||
Box(
|
||||
modifier = Modifier,
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.size(TangemTheme.dimens.size36)
|
||||
.clip(TangemTheme.shapes.roundedCorners8),
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
loading = {
|
||||
RectangleShimmer(radius = TangemTheme.dimens.radius8)
|
||||
},
|
||||
error = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape = TangemTheme.shapes.roundedCorners8)
|
||||
.background(TangemTheme.colors.field.primary),
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
CurrencyIconTopBadge(
|
||||
modifier = Modifier
|
||||
.offset(x = networkBadgeOffset, y = -networkBadgeOffset)
|
||||
.align(Alignment.TopEnd),
|
||||
iconResId = networkIconId,
|
||||
alpha = 1f,
|
||||
colorFilter = null,
|
||||
background = background,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.features.nft.details.block
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.ui.extensions.getActiveIconRes
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.nft.component.NFTDetailsBlockComponent
|
||||
import com.tangem.features.nft.details.block.ui.NFTDetailsBlock
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
class DefaultNFTDetailsBlockComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: NFTDetailsBlockComponent.Params,
|
||||
) : NFTDetailsBlockComponent, AppComponentContext by context {
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
NFTDetailsBlock(
|
||||
assetName = stringReference(params.nftAsset.name.orEmpty()),
|
||||
collectionName = stringReference(params.nftCollectionName),
|
||||
assetImage = params.nftAsset.media?.url,
|
||||
networkIconRes = getActiveIconRes(params.nftAsset.network.id.value),
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : NFTDetailsBlockComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: NFTDetailsBlockComponent.Params,
|
||||
): DefaultNFTDetailsBlockComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.tangem.features.nft.details.block.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.features.nft.common.ui.NFTLogo
|
||||
import com.tangem.features.nft.impl.R
|
||||
|
||||
@Composable
|
||||
internal fun NFTDetailsBlock(
|
||||
assetName: TextReference,
|
||||
collectionName: TextReference,
|
||||
assetImage: String?,
|
||||
networkIconRes: Int,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "NFT Asset",
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
NFTLogo(
|
||||
assetImage,
|
||||
networkIconRes,
|
||||
background = TangemTheme.colors.background.action,
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = assetName.resolveReference(),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
Text(
|
||||
text = collectionName.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun NFTDetailsBlock_Preview() {
|
||||
TangemThemePreview {
|
||||
NFTDetailsBlock(
|
||||
assetName = stringReference("NFT Asset Name"),
|
||||
collectionName = stringReference("NFT Collection"),
|
||||
assetImage = null,
|
||||
networkIconRes = R.drawable.img_polygon_22,
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -6,7 +6,7 @@ internal data class NFTDetailsUM(
|
|||
val nftAsset: NFTAssetUM,
|
||||
val onBackClick: () -> Unit,
|
||||
val onReadMoreClick: () -> Unit,
|
||||
val onSeeAllClick: () -> Unit,
|
||||
val onSeeAllTraitsClick: () -> Unit,
|
||||
val onExploreClick: () -> Unit,
|
||||
val onSendClick: () -> Unit,
|
||||
val bottomSheetConfig: TangemBottomSheetConfig?,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.tangem.features.nft.details.entity
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
internal data class NFTInfoBottomSheetConfig(
|
||||
val title: TextReference,
|
||||
val text: TextReference,
|
||||
) : TangemBottomSheetConfigContent
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.features.nft.details.model
|
||||
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
|
|
@ -9,7 +10,8 @@ import com.tangem.features.nft.details.entity.NFTAssetUM
|
|||
import com.tangem.features.nft.details.entity.NFTDetailsUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
|
|
@ -35,7 +37,7 @@ internal class NFTDetailsModel @Inject constructor(
|
|||
),
|
||||
onBackClick = ::navigateBack,
|
||||
onReadMoreClick = ::onReadMoreClick,
|
||||
onSeeAllClick = ::onSeeAllClick,
|
||||
onSeeAllTraitsClick = ::onSeeAllTraitsClick,
|
||||
onExploreClick = ::onExploreClick,
|
||||
onSendClick = ::onSendClick,
|
||||
bottomSheetConfig = null,
|
||||
|
|
@ -46,8 +48,12 @@ internal class NFTDetailsModel @Inject constructor(
|
|||
// TODO implement
|
||||
}
|
||||
|
||||
private fun onSeeAllClick() {
|
||||
// TODO implement
|
||||
private fun onSeeAllTraitsClick() {
|
||||
router.push(
|
||||
AppRoute.NFTAssetTraits(
|
||||
nftAsset = params.nftAsset,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onExploreClick() {
|
||||
|
|
@ -56,6 +62,13 @@ internal class NFTDetailsModel @Inject constructor(
|
|||
|
||||
private fun onSendClick() {
|
||||
// TODO implement
|
||||
// router.push(
|
||||
// AppRoute.NFTSend(
|
||||
// userWalletId = params.userWalletId,
|
||||
// nftAsset = params.nftAsset,
|
||||
// nftCollectionName = params.nftCollectionName,
|
||||
// ),
|
||||
// )
|
||||
}
|
||||
|
||||
private fun navigateBack() {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ import androidx.compose.ui.Modifier
|
|||
import com.tangem.core.ui.components.PrimaryButton
|
||||
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.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.nft.details.entity.NFTDetailsUM
|
||||
import com.tangem.features.nft.details.entity.NFTInfoBottomSheetConfig
|
||||
import com.tangem.features.nft.details.ui.bottomsheet.NFTInfoBottomSheet
|
||||
import com.tangem.features.nft.impl.R
|
||||
|
||||
@Composable
|
||||
|
|
@ -37,11 +40,12 @@ internal fun NFTDetails(state: NFTDetailsUM, modifier: Modifier = Modifier) {
|
|||
NFTDetailsAsset(
|
||||
state = state.nftAsset,
|
||||
onReadMoreClick = state.onReadMoreClick,
|
||||
onSeeAllClick = state.onSeeAllClick,
|
||||
onSeeAllTraitsClick = state.onSeeAllTraitsClick,
|
||||
onExploreClick = state.onExploreClick,
|
||||
modifier = Modifier
|
||||
.padding(innerPadding),
|
||||
)
|
||||
ShowBottomSheet(state.bottomSheetConfig)
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.Center,
|
||||
floatingActionButton = {
|
||||
|
|
@ -54,4 +58,12 @@ internal fun NFTDetails(state: NFTDetailsUM, modifier: Modifier = Modifier) {
|
|||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) {
|
||||
if (bottomSheetConfig == null) return
|
||||
when (bottomSheetConfig.content) {
|
||||
is NFTInfoBottomSheetConfig -> NFTInfoBottomSheet(bottomSheetConfig)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
internal fun NFTDetailsAsset(
|
||||
state: NFTAssetUM,
|
||||
onReadMoreClick: () -> Unit,
|
||||
onSeeAllClick: () -> Unit,
|
||||
onSeeAllTraitsClick: () -> Unit,
|
||||
onExploreClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -72,7 +72,7 @@ internal fun NFTDetailsAsset(
|
|||
NFTBlocksGroupAction(
|
||||
text = resourceReference(R.string.common_see_all),
|
||||
startIcon = { },
|
||||
onClick = onSeeAllClick,
|
||||
onClick = onSeeAllTraitsClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -103,7 +103,7 @@ private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetProvider::cla
|
|||
NFTDetailsAsset(
|
||||
state = state,
|
||||
onReadMoreClick = { },
|
||||
onSeeAllClick = { },
|
||||
onSeeAllTraitsClick = { },
|
||||
onExploreClick = { },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
package com.tangem.features.nft.details.ui.bottomsheet
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle
|
||||
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.nft.details.entity.NFTInfoBottomSheetConfig
|
||||
|
||||
@Composable
|
||||
fun NFTInfoBottomSheet(config: TangemBottomSheetConfig) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
TangemBottomSheet<NFTInfoBottomSheetConfig>(
|
||||
config = config,
|
||||
title = { content ->
|
||||
TangemBottomSheetTitle(title = content.title)
|
||||
},
|
||||
) { content ->
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = content.text.resolveReference(),
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.padding(
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
end = TangemTheme.dimens.spacing16,
|
||||
bottom = TangemTheme.dimens.spacing16,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun Preview_StakingInfoBottomSheet() {
|
||||
TangemThemePreview {
|
||||
NFTInfoBottomSheet(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = {},
|
||||
content = NFTInfoBottomSheetConfig(
|
||||
title = stringReference("Title"),
|
||||
text = stringReference(
|
||||
"""
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce varius neque vel ligula
|
||||
tincidunt, nec faucibus nulla ultricies. Maecenas euismod arcu in nunc volutpat,
|
||||
at bibendum eros lacinia. Proin hendrerit massa non velit congue,
|
||||
in volutpat nisi consequat. Sed vitae justo nec orci tincidunt malesuada.
|
||||
Nullam feugiat purus vel lectus efficitur, vel fringilla urna volutpat.
|
||||
Donec sagittis enim in metus lacinia, vel tempor nunc bibendum.
|
||||
""".trimIndent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,23 @@
|
|||
package com.tangem.features.nft
|
||||
package com.tangem.features.nft.di
|
||||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.nft.DefaultNFTFeatureToggles
|
||||
import com.tangem.features.nft.NFTFeatureToggles
|
||||
import com.tangem.features.nft.collections.DefaultNFTCollectionsComponent
|
||||
import com.tangem.features.nft.collections.model.NFTCollectionsModel
|
||||
import com.tangem.features.nft.component.NFTCollectionsComponent
|
||||
import com.tangem.features.nft.component.NFTDetailsBlockComponent
|
||||
import com.tangem.features.nft.component.NFTDetailsComponent
|
||||
import com.tangem.features.nft.component.NFTReceiveComponent
|
||||
import com.tangem.features.nft.component.NFTAssetTraitsComponent
|
||||
import com.tangem.features.nft.details.DefaultNFTDetailsComponent
|
||||
import com.tangem.features.nft.details.block.DefaultNFTDetailsBlockComponent
|
||||
import com.tangem.features.nft.details.model.NFTDetailsModel
|
||||
import com.tangem.features.nft.receive.DefaultNFTReceiveComponent
|
||||
import com.tangem.features.nft.receive.model.NFTReceiveModel
|
||||
import com.tangem.features.nft.traits.DefaultNFTAssetTraitsComponent
|
||||
import com.tangem.features.nft.traits.model.NFTAssetTraitsModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -62,4 +69,19 @@ internal interface NFTFeatureModuleBinds {
|
|||
@IntoMap
|
||||
@ClassKey(NFTDetailsModel::class)
|
||||
fun bindNFTDetailsModel(model: NFTDetailsModel): Model
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindNFTDetailsBlockComponentFactory(
|
||||
impl: DefaultNFTDetailsBlockComponent.Factory,
|
||||
): NFTDetailsBlockComponent.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindNFTTraitsComponentFactory(impl: DefaultNFTAssetTraitsComponent.Factory): NFTAssetTraitsComponent.Factory
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(NFTAssetTraitsModel::class)
|
||||
fun bindNFTTraitsModel(model: NFTAssetTraitsModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.features.nft.traits
|
||||
|
||||
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.nft.component.NFTAssetTraitsComponent
|
||||
import com.tangem.features.nft.traits.ui.NFTAssetTraits
|
||||
import com.tangem.features.nft.traits.model.NFTAssetTraitsModel
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultNFTAssetTraitsComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: NFTAssetTraitsComponent.Params,
|
||||
) : NFTAssetTraitsComponent, AppComponentContext by context {
|
||||
|
||||
private val model: NFTAssetTraitsModel = getOrCreateModel(params)
|
||||
|
||||
@Composable
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
|
||||
NFTAssetTraits(state)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : NFTAssetTraitsComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: NFTAssetTraitsComponent.Params,
|
||||
): DefaultNFTAssetTraitsComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.tangem.features.nft.traits.entity
|
||||
|
||||
data class NFTAssetTraitUM(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val value: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.features.nft.traits.entity
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class NFTAssetTraitsUM(
|
||||
val onBackClick: () -> Unit,
|
||||
val traits: ImmutableList<NFTAssetTraitUM>,
|
||||
)
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.tangem.features.nft.traits.model
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.nft.models.NFTAsset
|
||||
import com.tangem.features.nft.component.NFTAssetTraitsComponent
|
||||
import com.tangem.features.nft.traits.entity.NFTAssetTraitUM
|
||||
import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@ModelScoped
|
||||
internal class NFTAssetTraitsModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val router: Router,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
private val params: NFTAssetTraitsComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<NFTAssetTraitsUM> get() = _state
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
value = NFTAssetTraitsUM(
|
||||
traits = params.nftAsset.transform(),
|
||||
onBackClick = ::navigateBack,
|
||||
),
|
||||
)
|
||||
|
||||
private fun NFTAsset.transform(): ImmutableList<NFTAssetTraitUM> = this
|
||||
.traits
|
||||
.mapIndexed { index, trait ->
|
||||
NFTAssetTraitUM(
|
||||
id = index.toString(),
|
||||
name = trait.name,
|
||||
value = trait.value,
|
||||
)
|
||||
}.toPersistentList()
|
||||
|
||||
private fun navigateBack() {
|
||||
router.pop()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue